diff --git a/app/src/download/java/com/lin/magic/Firebase.kt b/app/src/download/java/com/lin/magic/Firebase.kt new file mode 100644 index 00000000..23e03457 --- /dev/null +++ b/app/src/download/java/com/lin/magic/Firebase.kt @@ -0,0 +1,15 @@ +package com.lin.magic + +import android.content.Context +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.crashlytics.FirebaseCrashlytics + +fun setAnalyticsCollectionEnabled(aContext: Context, aEnable: Boolean) { + FirebaseAnalytics.getInstance(aContext).setAnalyticsCollectionEnabled(aEnable) +} + +fun setCrashlyticsCollectionEnabled(aEnable: Boolean) { + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(aEnable) +} + + diff --git a/app/src/download/java/com/lin/magic/settings/fragment/SponsorshipSettingsFragment.kt b/app/src/download/java/com/lin/magic/settings/fragment/SponsorshipSettingsFragment.kt new file mode 100644 index 00000000..c0aa5f2d --- /dev/null +++ b/app/src/download/java/com/lin/magic/settings/fragment/SponsorshipSettingsFragment.kt @@ -0,0 +1,7 @@ +package com.lin.magic.settings.fragment + +/** + * Sponsorship settings for download variant. + * We just redirect users to Google Play Store if they want to sponsor us. + */ +class SponsorshipSettingsFragment : RedirectSponsorshipSettingsFragment() diff --git a/app/src/main/java/com/lin/magic/AccentTheme.kt b/app/src/main/java/com/lin/magic/AccentTheme.kt new file mode 100644 index 00000000..877b925c --- /dev/null +++ b/app/src/main/java/com/lin/magic/AccentTheme.kt @@ -0,0 +1,33 @@ +/* + * Copyright © 2020-2021 Jamal Rothfuchs + * Copyright © 2020-2021 Stéphane Lenclud + * Copyright © 2015 Anthony Restaino + */ + +package com.lin.magic + +import com.lin.magic.settings.preferences.IntEnum + +/** + * The available accent themes. + */ +enum class AccentTheme(override val value: Int) : + IntEnum { + DEFAULT_ACCENT(0), + PINK(1), + PURPLE(2), + DEEP_PURPLE(3), + INDIGO(4), + BLUE(5), + LIGHT_BLUE(6), + CYAN(7), + TEAL(8), + GREEN(9), + LIGHT_GREEN(10), + LIME(11), + YELLOW(12), + AMBER(13), + ORANGE(14), + DEEP_ORANGE(15), + BROWN(16) +} diff --git a/app/src/main/java/com/lin/magic/App.kt b/app/src/main/java/com/lin/magic/App.kt new file mode 100644 index 00000000..11baa43d --- /dev/null +++ b/app/src/main/java/com/lin/magic/App.kt @@ -0,0 +1,259 @@ +package com.lin.magic + +import com.lin.magic.activity.IncognitoActivity +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.di.DatabaseScheduler +import com.lin.magic.settings.preferences.DeveloperPreferences +import com.lin.magic.settings.preferences.LandscapePreferences +import com.lin.magic.settings.preferences.PortraitPreferences +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.installMultiDex +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.os.Bundle +import android.webkit.WebView +import com.jakewharton.threetenabp.AndroidThreeTen +import dagger.hilt.android.HiltAndroidApp +import com.lin.magic.settings.Config +import com.lin.magic.settings.preferences.ConfigurationPreferences +import io.reactivex.Scheduler +import io.reactivex.plugins.RxJavaPlugins +import timber.log.Timber +import javax.inject.Inject +import kotlin.system.exitProcess + + +@SuppressLint("StaticFieldLeak") +lateinit var app: App + +@HiltAndroidApp +class App : Application(), SharedPreferences.OnSharedPreferenceChangeListener, + Application.ActivityLifecycleCallbacks { + + @Inject internal lateinit var developerPreferences: DeveloperPreferences + @Inject internal lateinit var userPreferences: UserPreferences + @Inject internal lateinit var portraitPreferences: PortraitPreferences + @Inject internal lateinit var landscapePreferences: LandscapePreferences + @Inject internal lateinit var bookmarkModel: BookmarkRepository + @Inject @DatabaseScheduler + internal lateinit var databaseScheduler: Scheduler + + // Provide global access to current configuration preferences + internal var configPreferences: ConfigurationPreferences? = null + + //@Inject internal lateinit var buildInfo: BuildInfo + + // Used to be able to tell when our application was just started + var justStarted: Boolean = true + //Ugly way to pass our domain around for settings + var domain: String = "" + //Ugly way to pass our config around for settings + var config = Config("") + + /** + * Our app can runs in a different process when using the incognito activity. + * This tells us which process it is. + * However this is initialized after the activity creation when running on versions before Android 9. + */ + var incognito = false + private set(value) { + if (value) { + Timber.d("Incognito app process") + } + field = value + } + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + // We only need to install that multi DEX library when not doing minify code optimization, typically in debug, and on devices below API level 21. + // In fact from API level 21 and above Android Runtime (ART) is used rather than deprecated Dalvik. + // Since ART has multi DEX support built-in we don't need to install that DEX library from API level 21 and above. + // See: https://github.com/Slion/Magic/issues/116 + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT < 21) { + installMultiDex(context = base) + } + } + + + /** + * Setup Timber log engine according to user preferences + */ + private fun plantTimberLogs() { + + // Update Timber + if (userPreferences.logs) { + Timber.uprootAll() + Timber.plant(TimberLevelTree(userPreferences.logLevel.value)) + } else { + Timber.uprootAll() + } + + // Test our logs + Timber.v("Log verbose") + Timber.d("Log debug") + Timber.i("Log info") + Timber.w("Log warn") + Timber.e("Log error") + // We disabled that as we don't want our process to terminate + // Though it did not terminate the app in debug configuration on Huawei P30 Pro - Android 10 + //Timber.wtf("Log assert") + } + + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + Timber.v("onActivityCreated") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + if (activity is IncognitoActivity) { + // Needed as the process check we use below does not work before Android 9 + incognito = true + } + } + } + + override fun onActivityStarted(activity: Activity) { + Timber.v("onActivityStarted") + } + + override fun onActivityResumed(activity: Activity) { + Timber.v("onActivityResumed") + resumedActivity = activity + } + + override fun onActivityPaused(activity: Activity) { + Timber.v("onActivityPaused") + resumedActivity = null + } + + override fun onActivityStopped(activity: Activity) { + Timber.v("onActivityStopped") + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + Timber.v("onActivitySaveInstanceState") + } + + override fun onActivityDestroyed(activity: Activity) { + Timber.v("onActivityDestroyed") + com.lin.magic.utils.MemoryLeakUtils.clearNextServedView(activity, this@App) + } + + + /** + * + */ + override fun onCreate() { + app = this + registerActivityLifecycleCallbacks(this) + // SL: Use this to debug when launched from another app for instance + //Debug.waitForDebugger() + super.onCreate() + // No need to unregister I suppose cause this is for the life time of the application anyway + userPreferences.preferences.registerOnSharedPreferenceChangeListener(this) + + plantTimberLogs() + Timber.v("onCreate") + + AndroidThreeTen.init(this); + + if (BuildConfig.DEBUG) { + /* + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build()) + StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build()) + */ + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (getProcessName() == "$packageName:incognito") { + incognito = true + WebView.setDataDirectorySuffix("incognito") + } + } + + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, ex -> + if (userPreferences.crashLogs) { + com.lin.magic.utils.FileUtils.writeCrashToStorage(ex) + } + + if (defaultHandler != null) { + defaultHandler.uncaughtException(thread, ex) + } else { + exitProcess(2) + } + } + + // TODO: Remove that once we are done with ReactiveX + RxJavaPlugins.setErrorHandler { throwable: Throwable? -> + if (userPreferences.crashLogs && throwable != null) { + com.lin.magic.utils.FileUtils.writeCrashToStorage(throwable) + throw throwable + } + } + + // Apply locale + val requestLocale = com.lin.magic.locale.LocaleUtils.requestedLocale(userPreferences.locale) + com.lin.magic.locale.LocaleUtils.updateLocale(this, requestLocale) + + // Import default bookmarks if none present + // Now doing this synchronously as on fast devices it could result in not showing the bookmarks on first start + if (bookmarkModel.count()==0L) { + Timber.d("Create default bookmarks") + val assetsBookmarks = com.lin.magic.database.bookmark.BookmarkExporter.importBookmarksFromAssets(this@App) + bookmarkModel.addBookmarkList(assetsBookmarks) + } + + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true) + } + + } + + + companion object { + + // Used to track current activity + // Apparently we take care of not leaking it above + @SuppressLint("StaticFieldLeak") + var resumedActivity: Activity? = null + private set + + /** + * Used to get current activity context in order to access current theme. + */ + fun currentContext() : Context { + return resumedActivity + ?: app + } + + /** + * Was needed to patch issue with Homepage displaying system language when user selected another language + */ + fun setLocale() { + val requestLocale = com.lin.magic.locale.LocaleUtils.requestedLocale(app.userPreferences.locale) + com.lin.magic.locale.LocaleUtils.updateLocale(app, requestLocale) + } + + } + + /** + * + */ + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == getString(R.string.pref_key_log_level) || key == getString(R.string.pref_key_logs)) { + // Update Timber according to changed preferences + plantTimberLogs() + } + } + +} diff --git a/app/src/main/java/com/lin/magic/AppTheme.kt b/app/src/main/java/com/lin/magic/AppTheme.kt new file mode 100644 index 00000000..681e5513 --- /dev/null +++ b/app/src/main/java/com/lin/magic/AppTheme.kt @@ -0,0 +1,14 @@ +package com.lin.magic + +import com.lin.magic.settings.preferences.IntEnum + +/** + * The available app themes. + */ +enum class AppTheme(override val value: Int) : + IntEnum { + LIGHT(0), + DARK(1), + BLACK(2), + DEFAULT(3) +} diff --git a/app/src/main/java/com/lin/magic/Capabilities.kt b/app/src/main/java/com/lin/magic/Capabilities.kt new file mode 100644 index 00000000..b48fd0e6 --- /dev/null +++ b/app/src/main/java/com/lin/magic/Capabilities.kt @@ -0,0 +1,22 @@ +package com.lin.magic + +import android.os.Build + +/** + * Capabilities that are specific to certain API levels. + */ +enum class Capabilities { + FULL_INCOGNITO, + WEB_RTC, + THIRD_PARTY_COOKIE_BLOCKING +} + +/** + * Returns true if the capability is supported, false otherwise. + */ +val Capabilities.isSupported: Boolean + get() = when (this) { + Capabilities.FULL_INCOGNITO -> Build.VERSION.SDK_INT >= 28 + Capabilities.WEB_RTC -> Build.VERSION.SDK_INT >= 21 + Capabilities.THIRD_PARTY_COOKIE_BLOCKING -> Build.VERSION.SDK_INT >= 21 + } diff --git a/app/src/main/java/com/lin/magic/Component.kt b/app/src/main/java/com/lin/magic/Component.kt new file mode 100644 index 00000000..2ffad2eb --- /dev/null +++ b/app/src/main/java/com/lin/magic/Component.kt @@ -0,0 +1,51 @@ +package com.lin.magic + + +import androidx.lifecycle.DefaultLifecycleObserver +import kotlinx.coroutines.* + +/** + * Magic component + */ +abstract class Component : DefaultLifecycleObserver /*: androidx.lifecycle.ViewModel()*/ { + + // Setup an async scope on the main/UI thread dispatcher. + // This one as opposed to viwModelScope will not be cancelled therefore all operation will complete before the process quits. + // Use this if you want to manipulate views and other object bound to the UI thread. + val iScopeMainThread = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + // Only use this for operations that do not need the UI thread as they will run on another thread. + // Typically used for file write or read operations. + val iScopeThreadPool = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /* +class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope { + override val coroutineContext: CoroutineContext = context + + override fun close() { + coroutineContext.cancel() + } +}*/ + + /* + override fun onCleared() { + super.onCleared() + // This was not working here cause that callback comes after viewModelScope and all its jobs are cancelled +// runBlocking { +// viewModelScope.join() +// } + + // Make sure all async operations from our ViewModels are completed before we quit. + // This is needed as the ViewModel default behaviour is to cancel all outstanding operations. + // That should make sure sessions are always saved properly. + //runBlocking { + //ioScope.job()?.join() + //} + + + //ioScope.cancel() + } + + */ + +} + diff --git a/app/src/main/java/com/lin/magic/Entitlement.kt b/app/src/main/java/com/lin/magic/Entitlement.kt new file mode 100644 index 00000000..b6747e5b --- /dev/null +++ b/app/src/main/java/com/lin/magic/Entitlement.kt @@ -0,0 +1,23 @@ +package com.lin.magic + +class Entitlement { + companion object { + // @JvmStatic is there to avoid having to use the companion object when calling that function + // See: https://www.baeldung.com/kotlin-static-methods + @JvmStatic + fun maxTabCount(aSponsorship: Sponsorship): Int { + val kMaxTabCount = 10000; + return when (aSponsorship) { + Sponsorship.TIN -> 20 + Sponsorship.BRONZE -> kMaxTabCount + Sponsorship.SILVER -> kMaxTabCount + Sponsorship.GOLD -> kMaxTabCount + Sponsorship.PLATINUM -> kMaxTabCount + Sponsorship.DIAMOND -> kMaxTabCount + // Defensive + //else -> kMaxTabCount + } + } + } +} + diff --git a/app/src/main/java/com/lin/magic/ForwardingListener.kt b/app/src/main/java/com/lin/magic/ForwardingListener.kt new file mode 100644 index 00000000..1ac6e08e --- /dev/null +++ b/app/src/main/java/com/lin/magic/ForwardingListener.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2016 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. + */ +package com.lin.magic + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.view.ViewConfiguration +import androidx.annotation.RestrictTo +import androidx.appcompat.view.menu.ShowableListMenu +//import androidx.appcompat.widget.DropDownListView + +/** + * Abstract class that forwards touch events to a [ShowableListMenu]. + * + * SL: We might be able to use that as a base to implement our drag-to-open + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +abstract class ForwardingListener( + /** Source view from which events are forwarded. */ + val mSrc: View +) : + OnTouchListener, + View.OnAttachStateChangeListener { + /** Scaled touch slop, used for detecting movement outside bounds. */ + private val mScaledTouchSlop: Float + + /** Timeout before disallowing intercept on the source's parent. */ + private val mTapTimeout: Int + + /** Timeout before accepting a long-press to start forwarding. */ + private val mLongPressTimeout: Int + + /** Runnable used to prevent conflicts with scrolling parents. */ + private var mDisallowIntercept: Runnable? = null + + /** Runnable used to trigger forwarding on long-press. */ + private var mTriggerLongPress: Runnable? = null + + /** Whether this listener is currently forwarding touch events. */ + private var mForwarding = false + + /** The id of the first pointer down in the current event stream. */ + private var mActivePointerId = 0 + + /** + * Temporary Matrix instance + */ + private val mTmpLocation = IntArray(2) + + init { + mSrc.isLongClickable = true + mSrc.addOnAttachStateChangeListener(this) + mScaledTouchSlop = ViewConfiguration.get(mSrc.context).scaledTouchSlop.toFloat() + mTapTimeout = ViewConfiguration.getTapTimeout() + + // Use a medium-press timeout. Halfway between tap and long-press. + mLongPressTimeout = (mTapTimeout + ViewConfiguration.getLongPressTimeout()) / 2 + } + + /** + * Returns the popup to which this listener is forwarding events. + * + * + * Override this to return the correct popup. If the popup is displayed + * asynchronously, you may also need to override + * [.onForwardingStopped] to prevent premature cancelation of + * forwarding. + * + * @return the popup to which this listener is forwarding events + */ + abstract val popup: ShowableListMenu? + + override fun onTouch(v: View, event: MotionEvent): Boolean { + val wasForwarding = mForwarding + val forwarding: Boolean + if (wasForwarding) { + forwarding = onTouchForwarded(event) || !onForwardingStopped() + } else { + forwarding = onTouchObserved(event) && onForwardingStarted() + if (forwarding) { + // Make sure we cancel any ongoing source event stream. + val now = SystemClock.uptimeMillis() + val e = MotionEvent.obtain( + now, now, MotionEvent.ACTION_CANCEL, + 0.0f, 0.0f, 0 + ) + mSrc.onTouchEvent(e) + e.recycle() + } + } + mForwarding = forwarding + return forwarding || wasForwarding + } + + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + mForwarding = false + mActivePointerId = MotionEvent.INVALID_POINTER_ID + if (mDisallowIntercept != null) { + mSrc.removeCallbacks(mDisallowIntercept) + } + } + + /** + * Called when forwarding would like to start. + * + * + * By default, this will show the popup returned by [.getPopup]. + * It may be overridden to perform another action, like clicking the + * source view or preparing the popup before showing it. + * + * @return true to start forwarding, false otherwise + */ + protected open fun onForwardingStarted(): Boolean { + val popup = popup + // TODO + /* + if (popup != null && !popup.isShowing) { + popup.show() + } + */ + return true + } + + /** + * Called when forwarding would like to stop. + * + * + * By default, this will dismiss the popup returned by + * [.getPopup]. It may be overridden to perform some other + * action. + * + * @return true to stop forwarding, false otherwise + */ + protected fun onForwardingStopped(): Boolean { + val popup = popup + // TODO + /* + if (popup != null && popup.isShowing) { + popup.dismiss() + } + + */ + return true + } + + /** + * Observes motion events and determines when to start forwarding. + * + * @param srcEvent motion event in source view coordinates + * @return true to start forwarding motion events, false otherwise + */ + private fun onTouchObserved(srcEvent: MotionEvent): Boolean { + val src = mSrc + if (!src.isEnabled) { + return false + } + val actionMasked = srcEvent.actionMasked + when (actionMasked) { + MotionEvent.ACTION_DOWN -> { + mActivePointerId = srcEvent.getPointerId(0) + if (mDisallowIntercept == null) { + mDisallowIntercept = DisallowIntercept() + } + src.postDelayed(mDisallowIntercept, mTapTimeout.toLong()) + if (mTriggerLongPress == null) { + mTriggerLongPress = TriggerLongPress() + } + src.postDelayed(mTriggerLongPress, mLongPressTimeout.toLong()) + } + + MotionEvent.ACTION_MOVE -> { + val activePointerIndex = srcEvent.findPointerIndex(mActivePointerId) + if (activePointerIndex >= 0) { + val x = srcEvent.getX(activePointerIndex) + val y = srcEvent.getY(activePointerIndex) + + // Has the pointer moved outside of the view? + if (!pointInView(src, x, y, mScaledTouchSlop)) { + clearCallbacks() + + // Don't let the parent intercept our events. + src.parent.requestDisallowInterceptTouchEvent(true) + return true + } + } + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> clearCallbacks() + } + return false + } + + private fun clearCallbacks() { + if (mTriggerLongPress != null) { + mSrc.removeCallbacks(mTriggerLongPress) + } + if (mDisallowIntercept != null) { + mSrc.removeCallbacks(mDisallowIntercept) + } + } + + fun onLongPress() { + clearCallbacks() + val src = mSrc + if (!src.isEnabled || src.isLongClickable) { + // Ignore long-press if the view is disabled or has its own + // handler. + return + } + if (!onForwardingStarted()) { + return + } + + // Don't let the parent intercept our events. + src.parent.requestDisallowInterceptTouchEvent(true) + + // Make sure we cancel any ongoing source event stream. + val now = SystemClock.uptimeMillis() + val e = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + src.onTouchEvent(e) + e.recycle() + mForwarding = true + } + + /** + * Handles forwarded motion events and determines when to stop + * forwarding. + * + * @param srcEvent motion event in source view coordinates + * @return true to continue forwarding motion events, false to cancel + */ + private fun onTouchForwarded(srcEvent: MotionEvent): Boolean { + val src = mSrc + val popup = popup + // TODO + /* + if (popup == null || !popup.isShowing) { + return false + } + */ + // TODO + /* + val dst = popup.listView as DropDownListView + if (dst == null || !dst.isShown) { + return false + } + */ + + + // Convert event to destination-local coordinates. + val dstEvent = MotionEvent.obtainNoHistory(srcEvent) + toGlobalMotionEvent(src, dstEvent) + // TODO + //toLocalMotionEvent(dst, dstEvent) + + // Forward converted event to destination view, then recycle it. + // TODO + // See what that does here: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/widget/DropDownListView.java;l=499?q=onForwardedEvent&ss=androidx%2Fplatform%2Fframeworks%2Fsupport + // Though we could just stick to our plan and just send fake ACTION_HOVER_MOVE events + //val handled = dst.onForwardedEvent(dstEvent, mActivePointerId) + dstEvent.recycle() + + // Always cancel forwarding when the touch stream ends. + val action = srcEvent.actionMasked + val keepForwarding = (action != MotionEvent.ACTION_UP + && action != MotionEvent.ACTION_CANCEL) + // TODO + return /*handled &&*/ keepForwarding + } + + /** + * Emulates View.toLocalMotionEvent(). This implementation does not handle transformations + * (scaleX, scaleY, etc). + */ + private fun toLocalMotionEvent(view: View, event: MotionEvent): Boolean { + val loc = mTmpLocation + view.getLocationOnScreen(loc) + event.offsetLocation(-loc[0].toFloat(), -loc[1].toFloat()) + return true + } + + /** + * Emulates View.toGlobalMotionEvent(). This implementation does not handle transformations + * (scaleX, scaleY, etc). + */ + private fun toGlobalMotionEvent(view: View, event: MotionEvent): Boolean { + val loc = mTmpLocation + view.getLocationOnScreen(loc) + event.offsetLocation(loc[0].toFloat(), loc[1].toFloat()) + return true + } + + private inner class DisallowIntercept internal constructor() : + Runnable { + override fun run() { + val parent = mSrc.parent + parent?.requestDisallowInterceptTouchEvent(true) + } + } + + private inner class TriggerLongPress internal constructor() : + Runnable { + override fun run() { + onLongPress() + } + } + + companion object { + private fun pointInView(view: View, localX: Float, localY: Float, slop: Float): Boolean { + return localX >= -slop && localY >= -slop && localX < view.right - view.left + slop && localY < view.bottom - view.top + slop + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/Publisher.kt b/app/src/main/java/com/lin/magic/Publisher.kt new file mode 100644 index 00000000..05fbe3c2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/Publisher.kt @@ -0,0 +1,14 @@ +package com.lin.magic + +/** + * Define build config publishers. + */ +enum class Publisher(val value: String) { + DOWNLOAD("download"), + PLAYSTORE("playstore"), + FDROID("fdroid"); + + override fun toString(): String { + return this.value + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/Sponsorship.kt b/app/src/main/java/com/lin/magic/Sponsorship.kt new file mode 100644 index 00000000..d3ca851b --- /dev/null +++ b/app/src/main/java/com/lin/magic/Sponsorship.kt @@ -0,0 +1,17 @@ +package com.lin.magic + +import com.lin.magic.settings.preferences.IntEnum + +/** + * Define sponsorships level. + * Declared in root package so that it can be used from BuildConfig. + */ +enum class Sponsorship(override val value: Int) : + IntEnum { + TIN(0), + BRONZE(1), + SILVER(2), + GOLD(3), + PLATINUM(4), + DIAMOND(5) +} diff --git a/app/src/main/java/com/lin/magic/TimberLevelTree.kt b/app/src/main/java/com/lin/magic/TimberLevelTree.kt new file mode 100644 index 00000000..35616940 --- /dev/null +++ b/app/src/main/java/com/lin/magic/TimberLevelTree.kt @@ -0,0 +1,14 @@ +package com.lin.magic + +import timber.log.Timber + +/** + * Timber tree which logs messages from the specified priority. + */ +class TimberLevelTree(private val iPriority: Int) : Timber.DebugTree() { + + override fun isLoggable(tag: String?, priority: Int): Boolean { + return priority >= iPriority + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/activity/IncognitoActivity.kt b/app/src/main/java/com/lin/magic/activity/IncognitoActivity.kt new file mode 100644 index 00000000..dc439c97 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/IncognitoActivity.kt @@ -0,0 +1,71 @@ +package com.lin.magic.activity + +import com.lin.magic.AppTheme +import com.lin.magic.Capabilities +import com.lin.magic.isSupported +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import android.webkit.CookieManager +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.Completable +import javax.inject.Inject + +@AndroidEntryPoint +class IncognitoActivity @Inject constructor(): WebBrowserActivity() { + + override fun provideThemeOverride(): AppTheme = + AppTheme.BLACK + + override fun provideAccentThemeOverride(): com.lin.magic.AccentTheme = + com.lin.magic.AccentTheme.PINK + + /** + * + */ + public override fun updateCookiePreference(): Completable = Completable.fromAction { + val cookieManager = CookieManager.getInstance() + if (Capabilities.FULL_INCOGNITO.isSupported) { + cookieManager.setAcceptCookie(userPreferences.cookiesEnabled) + } else { + cookieManager.setAcceptCookie(userPreferences.incognitoCookiesEnabled) + } + } + + /** + * + */ + override fun onNewIntent(intent: Intent) { + handleNewIntent(intent) + super.onNewIntent(intent) + } + + /** + * + */ + override fun onCreate(savedInstanceState: Bundle?) { + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + super.onCreate(savedInstanceState) + } + + @Suppress("RedundantOverride") + override fun onPause() = super.onPause() // saveOpenTabs(); + + override fun updateHistory(title: String?, url: String) = Unit // addItemToHistory(title, url) + + override fun isIncognito() = true + + override fun closeActivity() = closeBrowser() + + companion object { + /** + * Creates the intent with which to launch the activity. Adds the reorder to front flag. + */ + fun createIntent(context: Context, uri: Uri? = null) = Intent(context, IncognitoActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + data = uri + } + } +} diff --git a/app/src/main/java/com/lin/magic/activity/LocaleAwareActivity.kt b/app/src/main/java/com/lin/magic/activity/LocaleAwareActivity.kt new file mode 100644 index 00000000..dc642027 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/LocaleAwareActivity.kt @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +package com.lin.magic.activity + +import com.lin.magic.di.HiltEntryPoint +import com.lin.magic.di.UserPrefs +import com.lin.magic.settings.preferences.UserPreferences +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.TextUtilsCompat +import androidx.core.view.ViewCompat +import dagger.hilt.android.EntryPointAccessors +import com.lin.magic.app +import com.lin.magic.locale.LocaleUtils +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +//@AndroidEntryPoint +abstract class LocaleAwareActivity : + AppCompatActivity() { + @Volatile + private var mLastLocale: Locale? = null + + /** + We need to get our Theme before calling onCreate for settings theme to work. + However onCreate does the Hilt injections so we did not have access to [LocaleAwareActivity.userPreferences] early enough. + Fortunately we can access our Hilt entry point early as shown below. + */ + private val hiltEntryPoint = EntryPointAccessors.fromApplication(app, HiltEntryPoint::class.java) + val userPreferences: UserPreferences = hiltEntryPoint.userPreferences + + @UserPrefs + @Inject + lateinit var userSharedPreferences: SharedPreferences + + /** + * Is called whenever the application locale has changed. Your Activity must either update + * all localised Strings, or replace itself with an updated version. + */ + abstract fun onLocaleChanged() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mLastLocale = LocaleUtils.requestedLocale(userPreferences.locale) + LocaleUtils.updateLocale(this, mLastLocale) + setLayoutDirection(window.decorView, mLastLocale) + } + + /** + * Upon configuration change our new config is reset to system locale. + * Locale.geDefault is also reset to system local apparently. + * That's also true if locale was previously change on the application context. + * Therefore we don't bother with application context for now. + * + * @param newConfig + */ + override fun onConfigurationChanged(newConfig: Configuration) { + val requestedLocale = LocaleUtils.requestedLocale(userPreferences.locale) + Timber.v("Config changed - Last locale: $mLastLocale") + Timber.v("Config changed - Requested locale: $requestedLocale") + Timber.v("Config changed - New config locale (ignored): " + newConfig.locale) + + // Check if our request local was changed + if (requestedLocale == mLastLocale) { + // Requested locale is the same make sure we apply it anew as it was reset in our new config + LocaleUtils.updateLocale(this, mLastLocale) + setLayoutDirection(window.decorView, mLastLocale) + } else { + // Requested locale was changed, we will need to restart our activity then + localeChanged(requestedLocale) + } + super.onConfigurationChanged(newConfig) + } + + /** + * + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + //onConfigurationChanged(getResources().getConfiguration()); + } + + /** + * + * @param aNewLocale + */ + private fun localeChanged(aNewLocale: Locale) { + Timber.v("Apply locale: $aNewLocale") + mLastLocale = aNewLocale + onLocaleChanged() + } + + override fun onResume() { + super.onResume() + val requestedLocale = LocaleUtils.requestedLocale(userPreferences.locale) + Timber.v("Resume - Last locale: $mLastLocale") + Timber.v("Resume - Requested locale: $requestedLocale") + + // Check if locale was changed as we were paused, apply new locale as needed + if (requestedLocale != mLastLocale) { + localeChanged(requestedLocale) + } + } + + companion object { + private const val TAG = "LocaleAwareActivity" + + /** + * Force set layout direction to RTL or LTR by Locale. + * + * @param view + * @param locale + */ + fun setLayoutDirection(view: View?, locale: Locale?) { + when (TextUtilsCompat.getLayoutDirectionFromLocale(locale)) { + ViewCompat.LAYOUT_DIRECTION_RTL -> ViewCompat.setLayoutDirection(view!!, ViewCompat.LAYOUT_DIRECTION_RTL) + ViewCompat.LAYOUT_DIRECTION_LTR -> ViewCompat.setLayoutDirection(view!!, ViewCompat.LAYOUT_DIRECTION_LTR) + else -> ViewCompat.setLayoutDirection(view!!, ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/activity/MainActivity.kt b/app/src/main/java/com/lin/magic/activity/MainActivity.kt new file mode 100644 index 00000000..af057300 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/MainActivity.kt @@ -0,0 +1,58 @@ +package com.lin.magic.activity + +import com.lin.magic.R +import android.content.Intent +import android.view.KeyEvent +import android.webkit.CookieManager +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.Completable +import javax.inject.Inject + +/** + * Not used in incognito mode + */ +@AndroidEntryPoint +class MainActivity @Inject constructor(): WebBrowserActivity() { + + + public override fun updateCookiePreference(): Completable = Completable.fromAction { + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(userPreferences.cookiesEnabled) + } + + + override fun onNewIntent(intent: Intent) = + if (intent.action == INTENT_PANIC_TRIGGER) { + panicClean() + } else { + handleNewIntent(intent) + super.onNewIntent(intent) + } + + override fun updateHistory(title: String?, url: String) = addItemToHistory(title, url) + + override fun isIncognito() = false + + // TODO: review how this is used and get rid of it + override fun closeActivity() { + performExitCleanUp() + moveTaskToBack(true) + } + + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && event.isCtrlPressed) { + when (event.keyCode) { + KeyEvent.KEYCODE_P -> + // Open a new private window + if (event.isShiftPressed) { + startActivity(IncognitoActivity.createIntent(this)) + overridePendingTransition(R.anim.slide_up_in, R.anim.fade_out_scale) + return true + } + } + } + return super.dispatchKeyEvent(event) + } + +} diff --git a/app/src/main/java/com/lin/magic/activity/ReadabilityActivity.kt b/app/src/main/java/com/lin/magic/activity/ReadabilityActivity.kt new file mode 100644 index 00000000..87c9c472 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/ReadabilityActivity.kt @@ -0,0 +1,208 @@ +//package com.lin.magic.activity +// +//import android.annotation.SuppressLint +//import android.app.Activity +//import android.content.Context +//import android.content.Intent +//import android.graphics.drawable.ColorDrawable +//import android.net.Uri +//import android.os.Bundle +//import android.util.Base64 +//import android.util.Log +//import android.view.LayoutInflater +//import android.view.View +//import android.webkit.WebResourceRequest +//import android.webkit.WebSettings +//import android.webkit.WebView +//import android.webkit.WebViewClient +//import android.widget.CheckBox +//import androidx.appcompat.app.AppCompatActivity +//import com.google.android.material.snackbar.Snackbar +//import com.lin.magic.AppTheme +//import com.lin.magic.R +//import com.lin.magic.utils.ThemeUtils +//import java.io.IOException +//import java.io.InputStream +//import javax.inject.Inject +// +// +//class ReadabilityActivity: AppCompatActivity() { +// +// companion object { +// const val TAG = "ReadabilityActivity" +// const val ARG_KEY = "default:arg" +// private fun getIntent(context: Context, url: String): Intent { +// return Intent(context, ReadabilityActivity::class.java).apply { +// putExtra(ARG_KEY, url) +// } +// } +// +// fun launch(activity: Activity, url: String) { +// activity.startActivity(getIntent(activity, url)) +// } +// } +// +// private var themeId: AppTheme = AppTheme.LIGHT +// +// private lateinit var binding: ReadabilityActivityBinding +// +// @Inject +// internal lateinit var userPreferences: UserPreferences +// @Inject +// internal lateinit var logger: NoOpLogger +//// @Inject +//// lateinit var webViewFactory: WebViewFactory +// +// private var mTouchX = 0.0f +// private var mTouchY = 0.0f +// +// @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") +// override fun onCreate(savedInstanceState: Bundle?) { +// injector.inject(this) +// themeId = userPreferences.useTheme +// +// // set the theme +// when (themeId) { +// AppTheme.LIGHT -> { +// setTheme(R.style.Theme_SettingsTheme) +// window.setBackgroundDrawable(ColorDrawable(ThemeUtils.getPrimaryColor(this))) +// } +// AppTheme.DARK -> { +// setTheme(R.style.Theme_SettingsTheme_Dark) +// window.setBackgroundDrawable(ColorDrawable(ThemeUtils.getPrimaryColorDark(this))) +// } +// AppTheme.BLACK -> { +// setTheme(R.style.Theme_SettingsTheme_Black) +// window.setBackgroundDrawable(ColorDrawable(ThemeUtils.getPrimaryColorDark(this))) +// } +// } +// super.onCreate(savedInstanceState) +// binding = ReadabilityActivityBinding.inflate(LayoutInflater.from(this)) +// setContentView(binding.root) +// +// val url = intent.getStringExtra(ARG_KEY) +// Log.v("owp", "======>$url") +// // 配置WebView +// val webSettings: WebSettings = binding.webView.settings +// webSettings.javaScriptEnabled = true +// +// // 设置WebChromeClient,用于处理页面标题等 +// binding.webView.webViewClient = object : WebViewClient() { +// override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { +// val uri = request?.url +// // 检查URL是否具有一个schema,并且不是http或https +// if (uri?.scheme != null && uri.scheme != "http" && uri.scheme != "https") { +// // 这里填写你的schema,例如 "intent://" 或者 "yourapp://" +// showConfirmSnackbar(request.url?.toString() ?: "") +// return true +// } +// return super.shouldOverrideUrlLoading(view, request) +// } +// +// override fun onPageFinished(view: WebView, url: String) { +// /*val getReaderModeBodyTextJs = """ +// javascript:(function() { +// var documentClone = document.cloneNode(true); +// var article = new Readability(documentClone, {classesToPreserve: preservedClasses}).parse(); +// return article.textContent; +// })() +// """ +// +// evaluateMozReaderModeJs { +// binding.webView.evaluateJavascript(getReaderModeBodyTextJs) { text -> +// Log.v("owp", "==>${text.substring(1, text.length - 2)}") +// preloadNext() +// } +// }*/ +// val js = """ +// var script = document.createElement('script'); +// script.type = 'text/javascript'; +// script.src = '//cdn.bootcss.com/eruda/1.4.2/eruda.min.js'; +// document.body.appendChild(script); +// script.onload = function() { eruda.init(); }; +// """ +// binding.webView.evaluateJavascript(js) { +// binding.webView.postDelayed({preloadNext()}, 5000) +// } +// } +// } +// +// binding.webView.loadUrl(url ?: "") +// } +// +// private fun showConfirmSnackbar(url: String) { +// Snackbar.make( +// binding.webView, +// R.string.prohibit_redirects_app_tip, +// Snackbar.LENGTH_INDEFINITE +// ).setAction(R.string.action_open) { +// navigateToExternalApp(url) +// }.show() +// } +// +// private fun navigateToExternalApp(url: String) { +// val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) +// startActivity(intent) +// } +// +// private fun evaluateMozReaderModeJs(postAction: (() -> Unit)? = null) { +// val cssByteArray = getByteArrayFromAsset("readerview/ReaderView.css") +// injectCss(cssByteArray) +// +// val jsString = getStringFromAsset("readerview/Readability.js") +// binding.webView.evaluateJavascript(jsString) { +// binding.webView.evaluateJavascript("javascript:(function() { window.scrollTo(0, 0); })()", null) +// postAction?.invoke() +// } +// } +// +// private fun injectCss(bytes: ByteArray) { +// try { +// val encoded = Base64.encodeToString(bytes, Base64.NO_WRAP) +// binding.webView.loadUrl( +// "javascript:(function() {" + +// "var parent = document.getElementsByTagName('head').item(0);" + +// "var style = document.createElement('style');" + +// "style.type = 'text/css';" + +// "style.innerHTML = window.atob('" + encoded + "');" + +// "parent.appendChild(style)" + +// "})()" +// ) +// } catch (e: Exception) { +// e.printStackTrace() +// } +// } +// +// private fun getByteArrayFromAsset(fileName: String): ByteArray { +// return try { +// val assetInput: InputStream = assets.open(fileName) +// val buffer = ByteArray(assetInput.available()) +// assetInput.read(buffer) +// assetInput.close() +// +// buffer +// } catch (e: IOException) { +// // TODO Auto-generated catch block +// e.printStackTrace() +// ByteArray(0) +// } +// } +// +// private fun getStringFromAsset(fileName: String): String = assets.open(fileName).bufferedReader().use { it.readText() } +// +// private fun preloadNext() { +// val jsString = getStringFromAsset("PreloadBook.js") +// binding.webView.evaluateJavascript(jsString) { urls -> +// Log.v("owp", "5555555555-->$urls") +//// var parser = new PreloadNext.A3PLParser(); +//// binding.webView.evaluateJavascript("javascript:PreloadNext();") {} +//// binding.webView.evaluateJavascript("javascript:A3PLParser.init();") {} +//// binding.webView.evaluateJavascript("javascript:A3PLParser.parserDocument(document, window);") {} +//// binding.webView.evaluateJavascript("javascript:A3PLParser.getNextLinkObject();") {} +//// binding.webView.evaluateJavascript("javascript:A3PLParser.getNextLinkUrl();") { urls -> +//// Log.v("owp", "555==>${urls}") +//// } +// } +// } +// +//} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/activity/ReadingActivity.kt b/app/src/main/java/com/lin/magic/activity/ReadingActivity.kt new file mode 100644 index 00000000..d6644cd2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/ReadingActivity.kt @@ -0,0 +1,431 @@ +package com.lin.magic.activity + + +import com.lin.magic.AppTheme +import com.lin.magic.R +import com.lin.magic.di.MainScheduler +import com.lin.magic.di.NetworkScheduler +import com.lin.magic.dialog.BrowserDialog.setDialogSize +import com.lin.magic.extensions.isDarkTheme +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.AsyncTask +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.text.Html +import android.text.SpannableStringBuilder +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.Toolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.Scheduler +import net.dankito.readability4j.Readability4J +import org.jsoup.Jsoup +import java.io.* +import java.net.URL +import java.util.* +import javax.inject.Inject + +@AndroidEntryPoint +class ReadingActivity : ThemedActivity(), TextToSpeech.OnInitListener { + @JvmField + var mTitle: TextView? = null + + @JvmField + var mBody: TextView? = null + + + @JvmField + @Inject + @NetworkScheduler + var mNetworkScheduler: Scheduler? = null + + @JvmField + @Inject + @MainScheduler + var mMainScheduler: Scheduler? = null + + private lateinit var iTtsEngine: TextToSpeech + private var mInvert = false + private var mUrl: String? = null + private var file: Boolean = false + private var mTextSize = 0 + private var mProgressDialog: AlertDialog? = null + + /** + * Override our theme as needed according to current theme and invert mode. + */ + override fun provideThemeOverride(): AppTheme { + var applyDarkTheme = isDarkTheme() + applyDarkTheme = (applyDarkTheme && !userPreferences.invertColors) || (!applyDarkTheme && userPreferences.invertColors) + return if (applyDarkTheme) { + AppTheme.BLACK + } else { + AppTheme.LIGHT + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + overridePendingTransition(R.anim.slide_in_from_right, R.anim.fade_out_scale) + mInvert = userPreferences.invertColors + iTtsEngine = TextToSpeech(this, this) + + setContentView(R.layout.reading_view) + mTitle = findViewById(R.id.textViewTitle) + mBody = findViewById(R.id.textViewBody) + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + if (supportActionBar != null) supportActionBar!!.setDisplayHomeAsUpEnabled(true) + mTextSize = userPreferences!!.readingTextSize + mBody!!.textSize = getTextSize(mTextSize) + mTitle!!.text = getString(R.string.untitled) + mBody!!.text = getString(R.string.loading) + mTitle!!.visibility = View.INVISIBLE + mBody!!.visibility = View.INVISIBLE + val intent = intent + try { + if (!loadPage(intent)) { + //setText(getString(R.string.untitled), getString(R.string.loading_failed)); + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + @SuppressLint("RestrictedApi") + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.reading, menu) + + if (menu is MenuBuilder) { + val m: MenuBuilder = menu + m.setOptionalIconsVisible(true) + } + + return super.onCreateOptionsMenu(menu) + } + + private inner class loadData : AsyncTask() { + var extractedContentHtml: String? = null + var extractedContentHtmlWithUtf8Encoding: String? = null + var extractedContentPlainText: String? = null + var title: String? = null + var byline: String? = null + var excerpt: String? = null + + + override fun onPostExecute(aVoid: Void?) { + val html: String? = extractedContentHtmlWithUtf8Encoding?.replace("image copyright".toRegex(), resources.getString(R.string.reading_mode_image_copyright) + " ")?.replace("image caption".toRegex(), resources.getString(R.string.reading_mode_image_caption) + " ")?.replace("".toRegex(), "") + try { + val doc = Jsoup.parse(html) + for (element in doc.select("img")) { + element.remove() + } + setText(title, doc.outerHtml()) + dismissProgressDialog() + } + catch (e: Exception){ + mTitle!!.alpha = 1.0f + mTitle!!.visibility = View.VISIBLE + mTitle?.text = resources.getString(R.string.title_error) + dismissProgressDialog() + } + } + + override fun doInBackground(vararg params: Void?): Void? { + try { + val google = URL(mUrl) + val line = BufferedReader(InputStreamReader(google.openStream())) + var input: String? + val stringBuffer = StringBuffer() + while (line.readLine().also { input = it } != null) { + stringBuffer.append(input) + } + line.close() + val htmlData = stringBuffer.toString() + val readability4J = Readability4J(mUrl!!, htmlData) // url is just needed to resolve relative urls + val article = readability4J.parse() + extractedContentHtml = article.content + extractedContentHtmlWithUtf8Encoding = article.contentWithUtf8Encoding + extractedContentPlainText = article.textContent + title = article.title + byline = article.byline + excerpt = article.excerpt + } catch (e: IOException) { + e.printStackTrace() + } + return null + } + } + + protected fun makeLinkClickable(strBuilder: SpannableStringBuilder, span: URLSpan?) { + val start: Int = strBuilder.getSpanStart(span) + val end: Int = strBuilder.getSpanEnd(span) + val flags: Int = strBuilder.getSpanFlags(span) + val clickable: ClickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + mTitle!!.text = getString(R.string.untitled) + mBody!!.text = getString(R.string.loading) + mUrl = span?.url + loadData().execute() + // TODO: somehow TTS is broken after following a link + // Restarting the activity does not help for some reason + // We ought to check TTS error codes and debug that at some point + //launch(this@ReadingActivity, mUrl!!, file) + //finish() + + } + } + strBuilder.setSpan(clickable, start, end, flags) + strBuilder.removeSpan(span) + } + + protected fun setTextViewHTML(text: TextView, html: String?) { + val sequence: CharSequence = Html.fromHtml(html) + val strBuilder = SpannableStringBuilder(sequence) + val urls: Array = strBuilder.getSpans(0, sequence.length, URLSpan::class.java) + for (span in urls) { + makeLinkClickable(strBuilder, span) + } + text.setText(strBuilder) + text.movementMethod = LinkMovementMethod.getInstance() + } + + @Throws(IOException::class) + private fun loadPage(intent: Intent?): Boolean { + if (intent == null) { + return false + } + mUrl = intent.getStringExtra(LOAD_READING_URL) + file = intent.getBooleanExtra(LOAD_FILE, false) + if (mUrl == null) { + return false + } + else if (file){ + setText(mUrl, loadFile(this, mUrl)) + return false + } + if (supportActionBar != null) { + supportActionBar!!.title = com.lin.magic.utils.Utils.getDisplayDomainName(mUrl) + } + + // Build progress dialog + val progressView = LayoutInflater.from(this).inflate(R.layout.dialog_progress, null) + val builder = MaterialAlertDialogBuilder(this) + .setView(progressView) + .setCancelable(false) + mProgressDialog = builder.create() + val tv = progressView.findViewById(R.id.text_progress_bar) + tv.setText(R.string.loading) + mProgressDialog!!.show() + + + loadData().execute() + return true + } + + private fun dismissProgressDialog() { + if (mProgressDialog != null && mProgressDialog!!.isShowing) { + mProgressDialog!!.dismiss() + mProgressDialog = null + } + } + + private fun setText(title: String?, body: String?) { + if (mTitle == null || mBody == null) return + if (mTitle!!.visibility == View.INVISIBLE) { + mTitle!!.alpha = 1.0f + mTitle!!.visibility = View.VISIBLE + setTextViewHTML(mTitle!!, title) + //mTitle!!.text = title + } else { + mTitle!!.text = title + setTextViewHTML(mTitle!!, title) + } + if (mBody!!.visibility == View.INVISIBLE) { + mBody!!.alpha = 1.0f + mBody!!.visibility = View.VISIBLE + setTextViewHTML(mBody!!, body) + } else { + setTextViewHTML(mBody!!, body) + } + } + + override fun onDestroy() { + if (mProgressDialog != null && mProgressDialog!!.isShowing) { + mProgressDialog!!.dismiss() + mProgressDialog = null + } + super.onDestroy() + } + + override fun onPause() { + super.onPause() + if (isFinishing) { + overridePendingTransition(R.anim.fade_in_scale, R.anim.slide_out_to_right) + } + } + + override fun onStop() { + super.onStop() + // Otherwise TTS goes on if we go background which is not always what we want + // TODO: Could have a setting to support that? + iTtsEngine.stop() + } + + /** + * + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.invert_item -> { + userPreferences!!.invertColors = !mInvert + if (mUrl != null) { + launch(this, mUrl!!, file) + finish() + } + } + R.id.text_size_item -> { + val view = LayoutInflater.from(this).inflate(R.layout.dialog_seek_bar, null) + val bar = view.findViewById(R.id.text_size_seekbar) + bar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(view: SeekBar, size: Int, user: Boolean) { + mBody!!.textSize = getTextSize(size) + } + + override fun onStartTrackingTouch(arg0: SeekBar) {} + override fun onStopTrackingTouch(arg0: SeekBar) {} + }) + bar.max = 5 + bar.progress = mTextSize + val builder = MaterialAlertDialogBuilder(this) + .setView(view) + .setTitle(R.string.size) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface?, arg1: Int -> + mTextSize = bar.progress + mBody!!.textSize = getTextSize(mTextSize) + userPreferences!!.readingTextSize = bar.progress + } + val dialog: Dialog = builder.show() + setDialogSize(this, dialog) + } + R.id.tts -> { + // Toggle TTS + if (!iTtsEngine.isSpeaking) { + val text: String = mBody?.text.toString() + //TODO: check error codes + iTtsEngine.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) + } + else { + iTtsEngine.stop() + } + invalidateOptionsMenu() + } + else -> finish() + } + return super.onOptionsItemSelected(item) + } + + + + private fun loadFile(context: Context, name: String?): String? { + return try { + val fis: FileInputStream = context.openFileInput(name + ".txt") + + fis.bufferedReader().use { it.readText() } + } catch (e: IOException) { + e.printStackTrace() + null + } + } + + override fun onInit(status: Int) { + if (status == TextToSpeech.SUCCESS) { + var result: Int = iTtsEngine.setLanguage(Locale.getDefault()) + //iTtsEngine.stop() + + // Try falling back to US english then + if (result == TextToSpeech.LANG_MISSING_DATA + || result == TextToSpeech.LANG_NOT_SUPPORTED) { + result = iTtsEngine.setLanguage(Locale.US) + } + + // Check if that was working + if (result == TextToSpeech.LANG_MISSING_DATA + || result == TextToSpeech.LANG_NOT_SUPPORTED) { + Log.e("TTS", "Language is not supported") + } + + iTtsEngine.setOnUtteranceCompletedListener(TextToSpeech.OnUtteranceCompletedListener { + runOnUiThread { + invalidateOptionsMenu() + } + }) + + } else { + Log.e("TTS", "Initilization Failed") + } + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + val item = menu.findItem(R.id.tts) + if (iTtsEngine.isSpeaking) { + item.title = resources.getString(R.string.stop_tts) + } else { + item.title = resources.getString(R.string.tts) + } + return super.onPrepareOptionsMenu(menu) + } + + companion object { + private const val LOAD_READING_URL = "ReadingUrl" + private const val LOAD_FILE = "FileUrl" + + /** + * Launches this activity with the necessary URL argument. + * + * @param context The context needed to launch the activity. + * @param url The URL that will be loaded into reading mode. + */ + fun launch(context: Context, url: String, file: Boolean) { + val intent = Intent(context, ReadingActivity::class.java) + intent.putExtra(LOAD_READING_URL, url) + intent.putExtra(LOAD_FILE, file) + context.startActivity(intent) + } + + private const val XXLARGE = 30.0f + private const val XLARGE = 26.0f + private const val LARGE = 22.0f + private const val MEDIUM = 18.0f + private const val SMALL = 14.0f + private const val XSMALL = 10.0f + private fun getTextSize(size: Int): Float { + return when (size) { + 0 -> XSMALL + 1 -> SMALL + 2 -> MEDIUM + 3 -> LARGE + 4 -> XLARGE + 5 -> XXLARGE + else -> MEDIUM + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/activity/SettingsActivity.kt b/app/src/main/java/com/lin/magic/activity/SettingsActivity.kt new file mode 100644 index 00000000..454b8b2f --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/SettingsActivity.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2014 A.C.R. Development + */ +package com.lin.magic.activity + +import com.lin.magic.R +import com.lin.magic.extensions.findPreference +import com.lin.magic.extensions.findViewsByType +import com.lin.magic.settings.fragment.AbstractSettingsFragment +import com.lin.magic.settings.fragment.RootSettingsFragment +import com.lin.magic.settings.fragment.ResponsiveSettingsFragment +import android.os.Bundle +import android.text.TextUtils +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.doOnLayout +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import com.lin.magic.extensions.ihs +import timber.log.Timber + +const val SETTINGS_CLASS_NAME = "ClassName" + +/** + * TODO: Review title update implementation for both single and dual pane modes + * Currently it only really works for single pane. + * Meaning when you go to Portrait or Landscape settings in dual pane mode you don't know where you are. + */ +@AndroidEntryPoint +class SettingsActivity : ThemedSettingsActivity() { + + lateinit var responsive: ResponsiveSettingsFragment + private var iFragmentClassName: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + responsive = ResponsiveSettingsFragment() + + // That could be useful at some point + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, responsive) + .runOnCommit { + responsive.childFragmentManager.addOnBackStackChangedListener { + // Triggers when a sub menu is opened, portrait and Landscape settings for instance + //updateTitle() + } + } + .commit() + + // Set our toolbar as action bar so that our title is displayed + // See: https://stackoverflow.com/questions/27665018/what-is-the-difference-between-action-bar-and-newly-introduced-toolbar + setSupportActionBar(findViewById(R.id.settings_toolbar)) + setTitle(R.string.settings) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + //supportActionBar?.setDisplayShowTitleEnabled(true) + + iFragmentClassName = savedInstanceState?.getString(SETTINGS_CLASS_NAME) + + // Truncate title in the middle + findViewById(R.id.settings_toolbar).findViewsByType(TextView::class.java).forEach { + //Timber.d("Toolbar text: ${it.text}") + it.ellipsize = TextUtils.TruncateAt.MIDDLE + // it.ellipsize = TextUtils.TruncateAt.MARQUEE + // it.marqueeRepeatLimit = -1 + // it.isSelected = true + } + } + + + /** + * + */ + override fun onResume() { + Timber.d("$ihs : onResume") + super.onResume() + + // At this stage our preferences have been created + try { + // Start specified fragment if any + if (iFragmentClassName == null) { + val className = intent.extras!!.getString(SETTINGS_CLASS_NAME) + val classType = Class.forName(className!!) + startFragment(classType) + } else { + val classType = Class.forName(iFragmentClassName) + startFragment(classType) + } + // Prevent switching back to that settings page after screen rotation + //intent = null + } + catch(ex: Exception) { + // Just ignore + } + + updateTitleOnLayout() + } + + /** + * + */ + override fun onDestroy() { + Timber.d("$ihs : onDestroy") + super.onDestroy() + + //responsive = null + } + + /** + * + */ + fun updateTitleOnLayout(aGoingBack: Boolean = false) { + findViewById(android.R.id.content).doOnLayout { + if (aGoingBack) { + // This code path is not actually being used + // TODO: clean it up + updateTitle() + } else { + title = responsive.title() + } + } + } + + /** + * Fetch the currently loaded settings fragment. + */ + private fun currentFragment() : Fragment? { + + return if (responsive.childFragmentManager.fragments.isNotEmpty() && ((responsive.slidingPaneLayout.isOpen && responsive.slidingPaneLayout.isSlideable) /*||responsive.childFragmentManager.backStackEntryCount>0*/)) { + responsive.childFragmentManager.fragments.last() + } else if (responsive.childFragmentManager.fragments.isNotEmpty() && responsive.slidingPaneLayout.isOpen && !responsive.slidingPaneLayout.isSlideable) { + responsive.childFragmentManager.fragments.first() + } else { + supportFragmentManager.findFragmentById(R.id.settings) + } + + } + + + /** + * Update activity title as define by the current fragment + * Also still does not work properly on wide screens. + * TODO: This is not actually being used anymore, consider a clean up + */ + private fun updateTitle() + { + // TODO: could just be defensive, test rotation without it and remove if not needed + if (responsive.view==null) { + // Prevent crash upon screen rotation + return + } + + if (!responsive.slidingPaneLayout.isOpen /*|| !responsive.slidingPaneLayout.isSlideable*/) { + setTitle(R.string.settings) + } else { + // Make sure title is also set properly when coming back from second level preference screen + // Notably needed for portrait and landscape configuration settings + updateTitle(currentFragment()) + //title = responsive.iPreference?.title + } + } + + /** + * Update activity title as defined by the given [aFragment]. + */ + private fun updateTitle(aFragment : Fragment?) + { + Timber.d("updateTitle") + // Needed to update title after language change + (aFragment as? AbstractSettingsFragment)?.let { + Timber.d("updateTitle done") + title = it.title() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Make sure the back button closes the application + // See: https://stackoverflow.com/questions/14545139/android-back-button-in-the-title-bar + when (item.itemId) { + android.R.id.home -> { + doOnBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + doOnBackPressed() + } + + /** + * + */ + private fun doOnBackPressed() { + + // Deploy workaround to make sure we exit this activity when user hits back from top level fragments + // You can reproduce that issue by disabling that workaround and going in a nested settings fragment such as Look & Feel > Portrait + // Then hit back button twice won't exit the settings activity. You can't exit the settings activity anymore. + val doFinish = (responsive.childFragmentManager.backStackEntryCount==0 && (!responsive.slidingPaneLayout.isOpen || !responsive.slidingPaneLayout.isSlideable)) + //val doFinish = !responsive.slidingPaneLayout.isOpen + super.onBackPressed() + if (doFinish) { + finish() + } else { + // Do not update title if we exit the activity to avoid showing broken title before exit + responsive.popBreadcrumbs() + updateTitleOnLayout() + } + } + + + override fun onSaveInstanceState(outState: Bundle) { + // Save current activity title so we can set it again after a configuration change + //outState.putCharSequence(TITLE_TAG, title) + super.onSaveInstanceState(outState) + + // Persist current fragment to restore it after screen rotation for instance + // TODO: For some reason that does not work with Portrait and Landscape pages + responsive.iPreference?.fragment.let { + outState.putString(SETTINGS_CLASS_NAME, it) + } + + } + + + + override fun onSupportNavigateUp(): Boolean { + if (supportFragmentManager.popBackStackImmediate()) { + return true + } + return super.onSupportNavigateUp() + } + + + /** + * Start fragment matching the given type. + * That should only work if the currently loaded fragment is our root/header fragment. + */ + private fun startFragment(aClass: Class<*>) { + // We need to find the preference that's associated with that fragment, before we can start it. + (currentFragment() as? RootSettingsFragment)?.let { + it.preferenceScreen.findPreference(aClass)?.let { pref -> + it.onPreferenceTreeClick(pref) + } + } + } + + /** + * + */ + override fun setTitle(title: CharSequence?) { + Timber.d("setTitle: $title") + super.setTitle(title) + } +} diff --git a/app/src/main/java/com/lin/magic/activity/SplashActivity.kt b/app/src/main/java/com/lin/magic/activity/SplashActivity.kt new file mode 100644 index 00000000..a1fa3e2b --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/SplashActivity.kt @@ -0,0 +1,75 @@ +package com.lin.magic.activity + +import com.lin.magic.R +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import javax.inject.Inject + +/** + * Still needed a splash screen activity as the SplashScreen API would not play well with our themed activity. + * We just could not get our theme override to work then. + */ +@SuppressLint("CustomSplashScreen") +@AndroidEntryPoint +class SplashActivity @Inject constructor(): LocaleAwareActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Splashscreen actually crashes on Android 12 when targeting Android 13 + // https://stackoverflow.com/questions/72390747/crash-activity-client-record-must-not-be-null-to-execute-transaction-item-only + // See: https://issuetracker.google.com/issues/210886009 + //val skip = Build.VERSION.SDK_INT == Build.VERSION_CODES.S || Build.VERSION.SDK_INT == Build.VERSION_CODES.S_V2 + // Just skip it always it looks the same without it anyway + // Skipping it on Samsung Galaxy A22 with Android 13 looked ugly + val skip = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + if (skip) { + // Need to do that when not using splash screen otherwise we crash with: + // java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity. + setTheme(R.style.Theme_App_DayNight) + } + + if (!skip) { + // Setup our splash screen + // See: https://developer.android.com/guide/topics/ui/splash-screen + val splashScreen = installSplashScreen() + splashScreen.setOnExitAnimationListener { + // Callback once our splash screen is done + // Splash screen duration is define in our style in Theme.App.SplashScreen + // NOTE: Though it does not seem to be working, therefore we cab use the post delayed below + + // Remove our splash screen, though not needed since we are closing this activity anyway + //it.remove() + // Close this activity, with a defensive delay for smoother transitions on slower devices + //finish() + } + } + + //setContentView(R.layout.activity_splash) + + findViewById(android.R.id.content). + // Put this here as above in the callback it did not work on Android 12 + // It would be too much to ask Google engineer to test their code across Android versions… + postDelayed({ + Timber.d("SplashScreen skipped: $skip") + // Just start our main activity now for fastest loading + // TODO: check if we need onboarding + // Launch main activity + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + // + finish() + },0) + } + + + override fun onLocaleChanged() { + //TODO("Not yet implemented") + } +} diff --git a/app/src/main/java/com/lin/magic/activity/ThemedActivity.kt b/app/src/main/java/com/lin/magic/activity/ThemedActivity.kt new file mode 100644 index 00000000..dfa374e1 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/ThemedActivity.kt @@ -0,0 +1,134 @@ +package com.lin.magic.activity + +import com.lin.magic.AppTheme +import com.lin.magic.R +import com.lin.magic.extensions.getDrawable +import com.lin.magic.extensions.toBitmap +import com.lin.magic.utils.ThemeUtils +import android.app.ActivityManager +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import androidx.annotation.StyleRes + +//@AndroidEntryPoint +abstract class ThemedActivity : LocaleAwareActivity() { + + // TODO: Do we still need those? Are they working? Do we want to fix them? + protected var accentId: com.lin.magic.AccentTheme = userPreferences.useAccent + protected var themeId: AppTheme = userPreferences.useTheme + + /** + * Override this to provide an alternate theme that should be set for every instance of this + * activity regardless of the user's preference. + */ + protected open fun provideThemeOverride(): AppTheme? = null + + protected open fun provideAccentThemeOverride(): com.lin.magic.AccentTheme? = null + + /** + * Called after the activity is resumed + * and the UI becomes visible to the user. + * Called by onWindowFocusChanged only if + * onResume has been called. + */ + protected open fun onWindowVisibleToUserAfterResume() = Unit + + /** + * Implement this to provide themes resource style ids. + */ + @StyleRes + fun themeStyle(aTheme: AppTheme): Int { + return when (aTheme) { + AppTheme.LIGHT -> R.style.Theme_App_Light + AppTheme.DARK -> R.style.Theme_App_Dark + AppTheme.BLACK -> R.style.Theme_App_Black + AppTheme.DEFAULT -> R.style.Theme_App_DayNight + } + } + + @StyleRes + protected open fun accentStyle(accentTheme: com.lin.magic.AccentTheme): Int? = null + + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme before onCreate otherwise settings are broken + // That's apparently not an issue specific to Magic + applyTheme(provideThemeOverride()?:themeId) + applyAccent() + // NOTE: https://github.com/Slion/Magic/issues/308 + // Only now call on create which will do Hilt injections + super.onCreate(savedInstanceState) + setDefaultTaskDescriptor() + resetPreferences() + } + + /** + * + */ + private fun setDefaultTaskDescriptor() { + // Make sure we reset task description when an activity is created + //setTaskLabel(getString(R.string.app_name)) + // Looks like the new API has no effect Samsung on Tab S8 so weird +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// Timber.v("setTaskDescription") +// setTaskDescription(ActivityManager.TaskDescription.Builder() +// .setLabel(getString(R.string.app_name)) +// .setBackgroundColor(color) +// .setIcon(R.drawable.ic_lightning) +// .build()) +// } + + if (userPreferences.taskIcon) { + //val color = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK) + val color = getColor(R.color.ic_launcher_background) + val icon = getDrawable(R.drawable.ic_lightning_flavored, android.R.attr.state_enabled).toBitmap(aBackground = color) + setTaskDescription(ActivityManager.TaskDescription(getString(R.string.app_name),icon, color)) + } else { + setTaskDescription(ActivityManager.TaskDescription(getString(R.string.app_name))) + } + } + + /** + * + */ + protected fun resetPreferences() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (userPreferences.useBlackStatusBar) { + window.statusBarColor = Color.BLACK + } else { + window.statusBarColor = ThemeUtils.getStatusBarColor(this) + } + } + } + + /** + * Private because one should use [provideThemeOverride] to set our theme. + * Changing it during the lifetime of the activity or after super.[onCreate] call is not working properly. + */ + private fun applyTheme(themeId: AppTheme) { + setTheme(themeStyle(themeId)) + } + + /** + * + */ + private fun applyAccent() { + //accentStyle(accentId)?.let { setTheme(it) } + } + + /** + * Using this instead of recreate() because it does not work when handling resource changes I guess. + */ + protected fun restart() { + finish() + startActivity(Intent(this, javaClass)) + } + + /** + * See [LocaleAwareActivity.onLocaleChanged] + */ + override fun onLocaleChanged() { + restart() + } +} diff --git a/app/src/main/java/com/lin/magic/activity/ThemedBrowserActivity.kt b/app/src/main/java/com/lin/magic/activity/ThemedBrowserActivity.kt new file mode 100644 index 00000000..8ddc98b2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/ThemedBrowserActivity.kt @@ -0,0 +1,60 @@ +package com.lin.magic.activity + +import com.lin.magic.R +import android.os.Bundle +import com.lin.magic.AccentTheme + +//@AndroidEntryPoint +abstract class ThemedBrowserActivity : ThemedActivity() { + + private var shouldRunOnResumeActions = false + + override fun onCreate(savedInstanceState: Bundle?) { + //injector.inject(this) + super.onCreate(savedInstanceState) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus && shouldRunOnResumeActions) { + shouldRunOnResumeActions = false + onWindowVisibleToUserAfterResume() + } + } + + override fun onResume() { + super.onResume() + resetPreferences() + shouldRunOnResumeActions = true + if (themeId != userPreferences.useTheme) { + restart() + } + + if (accentId != userPreferences.useAccent) { + restart() + } + } + + override fun accentStyle(accentTheme: AccentTheme): Int? { + return when (accentTheme) { + AccentTheme.DEFAULT_ACCENT -> null + AccentTheme.PINK -> R.style.Accent_Pink + AccentTheme.PURPLE -> R.style.Accent_Puple + AccentTheme.DEEP_PURPLE -> R.style.Accent_Deep_Purple + AccentTheme.INDIGO -> R.style.Accent_Indigo + AccentTheme.BLUE -> R.style.Accent_Blue + AccentTheme.LIGHT_BLUE -> R.style.Accent_Light_Blue + AccentTheme.CYAN -> R.style.Accent_Cyan + AccentTheme.TEAL -> R.style.Accent_Teal + AccentTheme.GREEN -> R.style.Accent_Green + AccentTheme.LIGHT_GREEN -> R.style.Accent_Light_Green + AccentTheme.LIME -> R.style.Accent_Lime + AccentTheme.YELLOW -> R.style.Accent_Yellow + AccentTheme.AMBER -> R.style.Accent_Amber + AccentTheme.ORANGE -> R.style.Accent_Orange + AccentTheme.DEEP_ORANGE -> R.style.Accent_Deep_Orange + AccentTheme.BROWN -> R.style.Accent_Brown + } + } + +} diff --git a/app/src/main/java/com/lin/magic/activity/ThemedSettingsActivity.kt b/app/src/main/java/com/lin/magic/activity/ThemedSettingsActivity.kt new file mode 100644 index 00000000..483afe84 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/ThemedSettingsActivity.kt @@ -0,0 +1,46 @@ +package com.lin.magic.activity + +import com.lin.magic.R +import com.lin.magic.extensions.isDarkTheme +import com.lin.magic.extensions.setStatusBarIconsColor + + +abstract class ThemedSettingsActivity : ThemedActivity() { + + override fun onResume() { + super.onResume() + // Make sure icons have the right color + //window.setStatusBarIconsColor(foregroundColorFromBackgroundColor(ThemeUtils.getPrimaryColor(this)) == Color.BLACK && !userPreferences.useBlackStatusBar) + window.setStatusBarIconsColor(!(isDarkTheme() || userPreferences.useBlackStatusBar)) + resetPreferences() + if (userPreferences.useTheme != themeId) { + recreate() + } + + if (userPreferences.useAccent != accentId) { + recreate() + } + } + + override fun accentStyle(accentTheme: com.lin.magic.AccentTheme): Int? { + return when (accentTheme) { + com.lin.magic.AccentTheme.DEFAULT_ACCENT -> null + com.lin.magic.AccentTheme.PINK -> R.style.Accent_Pink + com.lin.magic.AccentTheme.PURPLE -> R.style.Accent_Puple + com.lin.magic.AccentTheme.DEEP_PURPLE -> R.style.Accent_Deep_Purple + com.lin.magic.AccentTheme.INDIGO -> R.style.Accent_Indigo + com.lin.magic.AccentTheme.BLUE -> R.style.Accent_Blue + com.lin.magic.AccentTheme.LIGHT_BLUE -> R.style.Accent_Light_Blue + com.lin.magic.AccentTheme.CYAN -> R.style.Accent_Cyan + com.lin.magic.AccentTheme.TEAL -> R.style.Accent_Teal + com.lin.magic.AccentTheme.GREEN -> R.style.Accent_Green + com.lin.magic.AccentTheme.LIGHT_GREEN -> R.style.Accent_Light_Green + com.lin.magic.AccentTheme.LIME -> R.style.Accent_Lime + com.lin.magic.AccentTheme.YELLOW -> R.style.Accent_Yellow + com.lin.magic.AccentTheme.AMBER -> R.style.Accent_Amber + com.lin.magic.AccentTheme.ORANGE -> R.style.Accent_Orange + com.lin.magic.AccentTheme.DEEP_ORANGE -> R.style.Accent_Deep_Orange + com.lin.magic.AccentTheme.BROWN -> R.style.Accent_Brown + } + } +} diff --git a/app/src/main/java/com/lin/magic/activity/WebBrowserActivity.kt b/app/src/main/java/com/lin/magic/activity/WebBrowserActivity.kt new file mode 100644 index 00000000..0e8d6ac2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/activity/WebBrowserActivity.kt @@ -0,0 +1,5121 @@ +/* + * Copyright © 2020 Stéphane Lenclud. All Rights Reserved. + * Copyright 2015 Anthony Restaino + */ + +package com.lin.magic.activity + +//import com.anthonycr.grant.PermissionsManager +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.LayoutTransition +import android.annotation.SuppressLint +import android.app.Activity +import android.app.ActivityManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Point +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.StateListDrawable +import android.media.MediaPlayer +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Message +import android.os.VibrationEffect +import android.os.Vibrator +import android.provider.MediaStore +import android.text.Editable +import android.text.TextWatcher +import android.view.GestureDetector +import android.view.Gravity +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.View.GONE +import android.view.View.INVISIBLE +import android.view.View.SYSTEM_UI_FLAG_FULLSCREEN +import android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION +import android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY +import android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN +import android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION +import android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE +import android.view.View.VISIBLE +import android.view.ViewConfiguration +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewPropertyAnimator +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.webkit.CookieManager +import android.webkit.ValueCallback +import android.webkit.WebChromeClient.CustomViewCallback +import android.webkit.WebView.HitTestResult.SRC_ANCHOR_TYPE +import android.webkit.WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE +import android.widget.AdapterView.OnItemClickListener +import android.widget.AutoCompleteTextView +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.Space +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +import android.widget.VideoView +import androidx.annotation.ColorInt +import androidx.annotation.IdRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.net.toUri +import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children +import androidx.core.view.doOnLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.customview.widget.ViewDragHelper +import androidx.databinding.DataBindingUtil +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.FragmentManager +import androidx.palette.graphics.Palette +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.volley.AuthFailureError +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.lin.magic.BuildConfig +import com.lin.magic.R +import com.lin.magic.adblock.AbpUserRules +import com.lin.magic.app +import com.lin.magic.browser.MenuMain +import com.lin.magic.browser.MenuWebPage +import com.lin.magic.browser.RecentTabsModel +import com.lin.magic.browser.TabModelFromBundle +import com.lin.magic.browser.TabsManager +import com.lin.magic.browser.TabsView +import com.lin.magic.browser.WebBrowser +import com.lin.magic.browser.bookmarks.BookmarksDrawerView +import com.lin.magic.browser.cleanup.ExitCleanup +import com.lin.magic.browser.sessions.SessionsPopupWindow +import com.lin.magic.browser.tabs.TabsDesktopView +import com.lin.magic.browser.tabs.TabsDrawerView +import com.lin.magic.database.Bookmark +import com.lin.magic.database.HistoryEntry +import com.lin.magic.database.SearchSuggestion +import com.lin.magic.database.WebPage +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.database.history.HistoryRepository +import com.lin.magic.databinding.ActivityMainBinding +import com.lin.magic.databinding.ToolbarContentBinding +import com.lin.magic.di.DatabaseScheduler +import com.lin.magic.di.DiskScheduler +import com.lin.magic.di.MainHandler +import com.lin.magic.di.MainScheduler +import com.lin.magic.di.PrefsLandscape +import com.lin.magic.di.PrefsPortrait +import com.lin.magic.di.configPrefs +import com.lin.magic.di.updateConfigPrefs +import com.lin.magic.dialog.BrowserDialog +import com.lin.magic.dialog.DialogItem +import com.lin.magic.dialog.LightningDialogBuilder +import com.lin.magic.enums.HeaderInfo +import com.lin.magic.extensions.canScrollVertically +import com.lin.magic.extensions.configId +import com.lin.magic.extensions.copyToClipboard +import com.lin.magic.extensions.dimen +import com.lin.magic.extensions.drawable +import com.lin.magic.extensions.drawableForState +import com.lin.magic.extensions.isDarkTheme +import com.lin.magic.extensions.isVirtualKeyboardVisible +import com.lin.magic.extensions.makeSnackbar +import com.lin.magic.extensions.onFocusLost +import com.lin.magic.extensions.onSizeChange +import com.lin.magic.extensions.onceOnScrollStateIdle +import com.lin.magic.extensions.px +import com.lin.magic.extensions.removeFromParent +import com.lin.magic.extensions.resetTarget +import com.lin.magic.extensions.resizeAndShow +import com.lin.magic.extensions.setGravityBottom +import com.lin.magic.extensions.setGravityTop +import com.lin.magic.extensions.setStatusBarIconsColor +import com.lin.magic.extensions.simulateTap +import com.lin.magic.extensions.snackbar +import com.lin.magic.extensions.tint +import com.lin.magic.extensions.toast +import com.lin.magic.html.bookmark.BookmarkPageFactory +import com.lin.magic.html.history.HistoryPageFactory +import com.lin.magic.html.homepage.HomePageFactory +import com.lin.magic.html.incognito.IncognitoPageFactory +import com.lin.magic.keyboard.Shortcuts +import com.lin.magic.locale.LocaleUtils +import com.lin.magic.notifications.IncognitoNotification +import com.lin.magic.search.SearchEngineProvider +import com.lin.magic.search.SuggestionsAdapter +import com.lin.magic.setAnalyticsCollectionEnabled +import com.lin.magic.setCrashlyticsCollectionEnabled +import com.lin.magic.settings.NewTabPosition +import com.lin.magic.settings.fragment.BottomSheetDialogFragment +import com.lin.magic.settings.fragment.DisplaySettingsFragment.Companion.MAX_BROWSER_TEXT_SIZE +import com.lin.magic.settings.fragment.DisplaySettingsFragment.Companion.MIN_BROWSER_TEXT_SIZE +import com.lin.magic.settings.fragment.SponsorshipSettingsFragment +import com.lin.magic.ssl.SslState +import com.lin.magic.ssl.createSslDrawableForState +import com.lin.magic.ssl.showSslDialog +import com.lin.magic.utils.DrawableUtils +import com.lin.magic.utils.ProxyUtils +import com.lin.magic.utils.QUERY_PLACE_HOLDER +import com.lin.magic.utils.StyleRemovingTextWatcher +import com.lin.magic.utils.ThemeUtils +import com.lin.magic.utils.Utils +import com.lin.magic.utils.WebUtils +import com.lin.magic.utils.adjustBottomSheet +import com.lin.magic.utils.foregroundColorFromBackgroundColor +import com.lin.magic.utils.isBookmarkUri +import com.lin.magic.utils.isBookmarkUrl +import com.lin.magic.utils.isDownloadsUrl +import com.lin.magic.utils.isHistoryUri +import com.lin.magic.utils.isHomeUri +import com.lin.magic.utils.isIncognitoUri +import com.lin.magic.utils.isSpecialUrl +import com.lin.magic.utils.shareUrl +import com.lin.magic.utils.smartUrlFilter +import com.lin.magic.view.BookmarkPageInitializer +import com.lin.magic.view.CodeView +import com.lin.magic.view.DownloadPageInitializer +import com.lin.magic.view.FreezableBundleInitializer +import com.lin.magic.view.HistoryPageInitializer +import com.lin.magic.view.HomePageInitializer +import com.lin.magic.view.IncognitoPageInitializer +import com.lin.magic.view.NoOpInitializer +import com.lin.magic.view.PullRefreshLayout +import com.lin.magic.view.ResultMessageInitializer +import com.lin.magic.view.SearchView +import com.lin.magic.view.UrlInitializer +import com.lin.magic.view.WebPageTab +import com.lin.magic.view.WebViewEx +import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.Completable +import io.reactivex.Scheduler +import io.reactivex.rxkotlin.subscribeBy +import junit.framework.Assert.assertNull +import org.json.JSONObject +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject +import kotlin.math.abs +import kotlin.system.exitProcess + + +/** + * + */ +@AndroidEntryPoint +abstract class WebBrowserActivity : ThemedBrowserActivity(), + WebBrowser, View.OnClickListener, PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { + + // Notifications + lateinit var CHANNEL_ID: String + + // Tab view being currently displayed + private val currentTabView: WebViewEx? + get () = tabsManager.currentTab?.webView + + // Only used to avoid setting up the same tab again + // Don't use it for anything else as it can potentially get destroyed anytime + private var lastTabView: View? = null + + // Our tab view back and front containers + // We swap them as needed to make sure our view animations are performed smoothly and without flicker + private lateinit var iTabViewContainerBack: PullRefreshLayout + private lateinit var iTabViewContainerFront: PullRefreshLayout + // Points to current tab animator or null if no tab animation is running + private var iTabAnimator: ViewPropertyAnimator? = null + + // Full Screen Video Views + private var fullscreenContainerView: FrameLayout? = null + private var videoView: VideoView? = null + private var customView: View? = null + + // Adapter + private var suggestionsAdapter: SuggestionsAdapter? = null + + // Callback + private var customViewCallback: CustomViewCallback? = null + private var uploadMessageCallback: ValueCallback? = null + private var filePathCallback: ValueCallback>? = null + + // Primitives + private var isFullScreen: Boolean = false + private var hideStatusBar: Boolean = false + + private var isImmersiveMode = false + private var verticalTabBar: Boolean = false + private var tabBarInDrawer: Boolean = false + private var swapBookmarksAndTabs: Boolean = false + + private var originalOrientation: Int = 0 + private var currentUiColor = Color.BLACK + var currentToolBarTextColor = Color.BLACK + private var keyDownStartTime: Long = 0 + private var searchText: String? = null + private var cameraPhotoPath: String? = null + + + // The singleton BookmarkManager + @Inject lateinit var bookmarkManager: BookmarkRepository + @Inject lateinit var historyModel: HistoryRepository + @Inject lateinit var searchEngineProvider: SearchEngineProvider + @Inject lateinit var inputMethodManager: InputMethodManager + @Inject lateinit var clipboardManager: ClipboardManager + @Inject lateinit var notificationManager: NotificationManager + @Inject @field:DiskScheduler + lateinit var diskScheduler: Scheduler + @Inject @field:DatabaseScheduler + lateinit var databaseScheduler: Scheduler + @Inject @field:MainScheduler + lateinit var mainScheduler: Scheduler + @Inject lateinit var homePageFactory: HomePageFactory + @Inject lateinit var incognitoPageFactory: IncognitoPageFactory + @Inject lateinit var incognitoPageInitializer: IncognitoPageInitializer + @Inject lateinit var bookmarkPageFactory: BookmarkPageFactory + @Inject lateinit var historyPageFactory: HistoryPageFactory + @Inject lateinit var historyPageInitializer: HistoryPageInitializer + @Inject lateinit var downloadPageInitializer: DownloadPageInitializer + @Inject lateinit var homePageInitializer: HomePageInitializer + @Inject lateinit var bookmarkPageInitializer: BookmarkPageInitializer + @Inject @field:MainHandler + lateinit var mainHandler: Handler + @Inject lateinit var proxyUtils: ProxyUtils + @Inject lateinit var bookmarksDialogBuilder: LightningDialogBuilder + @Inject lateinit var exitCleanup: ExitCleanup + @Inject lateinit var abpUserRules: AbpUserRules + // + @Inject lateinit var tabsManager: TabsManager + + // To be notified when preference are changed + @Inject @PrefsPortrait + lateinit var portraitSharedPrefs: SharedPreferences + @Inject @PrefsLandscape + lateinit var landscapeSharedPrefs: SharedPreferences + // Need to keep reference of listener otherwise they get garbage collected + // Used to apply changes live when configuration preferences are adjusted from options settings + private val configPrefsListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + Timber.d("Config prefs changed") + updateConfiguration() + } + + // HTTP + private lateinit var queue: RequestQueue + + // Image + private val backgroundDrawable = ColorDrawable() + private var incognitoNotification: IncognitoNotification? = null + + private var tabsView: TabsView? = null + private var bookmarksView: BookmarksDrawerView? = null + + // Menus + private lateinit var iMenuMain: MenuMain + private lateinit var iMenuWebPage: MenuWebPage + lateinit var iMenuSessions: SessionsPopupWindow + //TODO: put that in settings + private lateinit var tabsDialog: BottomSheetDialog + private lateinit var bookmarksDialog: BottomSheetDialog + + // Options settings menu + private val iBottomSheet = BottomSheetDialogFragment(supportFragmentManager) + + // Binding + lateinit var iBinding: ActivityMainBinding + lateinit var iBindingToolbarContent: ToolbarContentBinding + + // Toolbar Views + private lateinit var searchView: SearchView + private lateinit var buttonSessions: ImageButton + + // Settings + private var crashReport = true + private var analytics = true + private var showCloseTabButton = false + + private val longPressBackRunnable = Runnable { + // Disable this for now as it is popping up when exiting full screen video mode. + // See: https://github.com/Slion/Magic/issues/81 + //showCloseDialog(tabsManager.positionOf(tabsManager.currentTab)) + } + + // We had to use that to avoid crashes when using tab animations + private var iPlaceHolder: Space? = null + + /** + * Determines if the current browser instance is in incognito mode or not. + */ + public abstract fun isIncognito(): Boolean + + /** + * Choose the behavior when the controller closes the view. + */ + abstract override fun closeActivity() + + /** + * Choose what to do when the browser visits a website. + * + * @param title the title of the site visited. + * @param url the url of the site visited. + */ + abstract override fun updateHistory(title: String?, url: String) + + /** + * An observable which asynchronously updates the user's cookie preferences. + */ + protected abstract fun updateCookiePreference(): Completable + + override fun onCreate(savedInstanceState: Bundle?) { + Timber.v("onCreate") + // Need to go first to inject our components + super.onCreate(savedInstanceState) + // + updateConfigurationSharedPreferences() + // We want to control our decor + WindowCompat.setDecorFitsSystemWindows(window,false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.attributes.layoutInDisplayCutoutMode = configPrefs.cutoutMode.value + } + + // Register lifecycle observers + lifecycle.addObserver(tabsManager) + // + createKeyboardShortcuts() + + iPlaceHolder = Space(this).apply{ isVisible = false} + + if (app.justStarted) { + app.justStarted = false + // Since amazingly on Android you can't tell when your app is closed we do exit cleanup on start-up, go figure + // See: https://github.com/Slion/Magic/issues/106 + performExitCleanUp() + } + + iBinding = DataBindingUtil.setContentView(this, R.layout.activity_main) + + // Setup a callback when we need to apply window insets + ViewCompat.setOnApplyWindowInsetsListener(iBinding.root) { view, windowInsets -> + Timber.d("OnApplyWindowInsetsListener") + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + Timber.d("System insets: $insets") + //val imeVisible = windowInsets.isVisible(WindowInsetsCompat.Type.ime()) + val imeHeight = windowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom + + val gestureInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures()) + Timber.d("Gesture insets: $gestureInsets") + + view.updateLayoutParams { + // Don't apply vertical margins here as it would break our drawers status bar color + // Apply horizontal margin to our root view so that we fill the cutout in on Honor Magic V2 + leftMargin = insets.left //+ gestureInsets.left + rightMargin = insets.right //+ gestureInsets.right + // Make sure our UI does not get stuck below the IME virtual keyboard + // TODO: Do animation synchronization, see: https://developer.android.com/develop/ui/views/layout/sw-keyboard#synchronize-animation + bottomMargin = imeHeight + } + + iBinding.uiLayout.updateLayoutParams { + // Apply vertical margins for status and navigation bar to our UI layout + // Thus the drawers are still showing below the status bar + topMargin = insets.top + bottomMargin = insets.bottom //+ gestureInsets.bottom + //leftMargin = gestureInsets.left + //rightMargin = gestureInsets.right + } + + iBinding.leftDrawerContent.updateLayoutParams { + // Apply vertical margins for status and navigation bar to our drawer content + // Thus drawer content does not overlap with system UI + topMargin = insets.top + bottomMargin = insets.bottom + } + + iBinding.rightDrawerContent.updateLayoutParams { + // Apply vertical margins for status and navigation bar to our drawer content + // Thus drawer content does not overlap with system UI + topMargin = insets.top + bottomMargin = insets.bottom + } + + //windowInsets + WindowInsetsCompat.CONSUMED + } + + iTabViewContainerBack = iBinding.tabViewContainerOne + iTabViewContainerFront = iBinding.tabViewContainerTwo + + // Setup our find in page bindings + iBinding.findInPageInclude.searchQuery.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + // Trigger on setText + } + override fun afterTextChanged(s: Editable) { + if (!iSkipNextSearchQueryUpdate) { + tabsManager.currentTab?.find(s.toString()) + } + iSkipNextSearchQueryUpdate = false + } + }) + iBinding.findInPageInclude.buttonNext.setOnClickListener(this) + iBinding.findInPageInclude.buttonBack.setOnClickListener(this) + iBinding.findInPageInclude.buttonQuit.setOnClickListener(this) + + queue = Volley.newRequestQueue(this) + createMenuMain() + createMenuWebPage() + createMenuSessions() + tabsDialog = BottomSheetDialog(this) + bookmarksDialog = BottomSheetDialog(this) + + + if (isIncognito()) { + incognitoNotification = IncognitoNotification(this, notificationManager) + } + tabsManager.addTabNumberChangedListener { + if (isIncognito()) { + if (it == 0) { + incognitoNotification?.hide() + } else { + incognitoNotification?.show(it) + } + } + } + tabsManager.addTabNumberChangedListener(::updateTabNumber) + + // Setup our presenter + tabsManager.iWebBrowser = this + tabsManager.closedTabs = RecentTabsModel() + tabsManager.isIncognito = isIncognito() + + + initialize(savedInstanceState) + + if (BuildConfig.FLAVOR.contains("slionsFullDownload")) { + tabsManager.doOnceAfterInitialization { + // Check for update after a short delay, hoping user engagement is better and message more visible + mainHandler.postDelayed({ checkForUpdates() }, 3000) + } + } else if (BuildConfig.FLAVOR_BRAND != "slions") { + // As per CPAL license show attribution if not slions brand + makeSnackbar("",5000, Gravity.TOP).setAction("Powered by ⚡Magic") { + Intent(Intent.ACTION_VIEW).apply{ + data = Uri.parse(getString(R.string.url_magic_home_page)) + putExtra("SOURCE", "SELF") + setPackage(packageName) + startActivity(this) + } + }.show() + } + + // Welcome new users or notify of updates + tabsManager.doOnceAfterInitialization { + // If our version code was changed + if (userPreferences.versionCode != BuildConfig.VERSION_CODE) { + if (userPreferences.versionCode==0 + // Added this check to avoid show welcome message to existing installation + // TODO: Remove that a few versions down the road + && tabsManager.iSessions.count()==1 && tabsManager.allTabs.count()==1) { + // First run + welcomeToMagic() + } else { + // Version was updated + notifyVersionUpdate() + } + // Persist our current version so that we don't kick in next time + userPreferences.versionCode = BuildConfig.VERSION_CODE + } + } + + // This callback is trigger after we switch session, Could be useful at some point + //tabsManager.doAfterInitialization {} + + // Hook in buttons with onClick handler + iBindingToolbarContent.buttonReload.setOnClickListener(this) + + } + + /** + * Call this whenever our configuration could have changed. + * It takes care of setting our global configPrefs and make sure we are listening to changes. + */ + private fun updateConfigurationSharedPreferences() { + // + updateConfigPrefs() + // I reckon that should prevent accumulating listeners + // Single unneeded notifications should not impact performance and functionality + configPrefs.preferences.unregisterOnSharedPreferenceChangeListener(configPrefsListener) + configPrefs.preferences.registerOnSharedPreferenceChangeListener(configPrefsListener) + } + + /** + * Update our views according to current configuration. + * Notably called when the configuration changes or when configuration preferences are adjusted. + */ + private fun updateConfiguration(aConfig: Configuration = resources.configuration) { + Timber.d("updateConfiguration") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (window.attributes.layoutInDisplayCutoutMode != configPrefs.cutoutMode.value) { + // We don't seem to be able to apply that without restarting the activity + window.attributes.layoutInDisplayCutoutMode = configPrefs.cutoutMode.value + // This makes sure the newly set cutout mode is applied + window.attributes = window.attributes + // TODO adjust attributes for all our dialog windows + } + } + + setupDrawers() + setFullscreenIfNeeded() + if (!setupTabBar(aConfig)) { + // useBottomsheets settings could have changed + addTabsViewToParent() + } + + setupToolBar(aConfig) + setupBookmarksView() + + // Can't find a proper event to do that after the configuration changes were applied so we just delay it + mainHandler.postDelayed({ + setupToolBar() + setupPullToRefresh(aConfig) + // For embedded tab bars modes + tryScrollToCurrentTab() + + iBinding.drawerLayout.requestLayout() + },500); + + //TODO: on Samsung Galaxy Tab S8 Ultra after turn on status bar in options configuration is does not render properly until we change tab + // The thing below did not help for some reason. Would be nice to find a fix for that. +// tabsManager.currentTab?.let{ +// tabsManager.tabChanged(tabsManager.tabsModel.indexOfTab(it),false,false) +// } + + } + + /** + * + */ + private fun createMenuSessions() { + iMenuSessions = SessionsPopupWindow(layoutInflater) + // Make it full screen gesture friendly + iMenuSessions.setOnDismissListener { justClosedMenuCountdown() } + } + + // Used to avoid running that too many times, by keeping a reference to it we can cancel that runnable + // That works around graphical glitches happening when run too many times + var onSizeChangeRunnable : Runnable = Runnable {}; + // Used to cancel that runnable as needed + private var resetBackgroundColorRunnable : Runnable = Runnable {}; + + /** + * Used for both tabs and bookmarks. + */ + private fun createBottomSheetDialog(aContentView: View) : BottomSheetDialog { + val dialog = BottomSheetDialog(this) + + // Set up BottomSheetDialog + dialog.window?.decorView?.systemUiVisibility = window.decorView.systemUiVisibility + dialog.window?.setFlags(window.attributes.flags, WindowManager.LayoutParams.FLAG_FULLSCREEN) + // TODO: All windows should have consistent cutout modes + //dialog.window?.let {WindowCompat.setDecorFitsSystemWindows(it,false)} + //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // dialog.window?.attributes?.layoutInDisplayCutoutMode = configPrefs.cutoutMode.value + //} + //dialog.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + //dialog.window?.setFlags(dialog.window?.attributes!!.flags, WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + //dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + + // Needed to make sure our bottom sheet shows below our session pop-up + // TODO: that breaks status bar icon color with our light theme somehow + //dialog.window?.attributes?.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG; + // + + // We need to set private data member edgeToEdgeEnabled to true to get full screen effect + // That won't be needed past material:1.4.0-alpha02 as it is read from our theme definition from then on + //val field = BottomSheetDialog::class.java.getDeclaredField("edgeToEdgeEnabled") + //field.isAccessible = true + //field.setBoolean(dialog, true) + + // + aContentView.removeFromParent() + dialog.setContentView(aContentView) + dialog.behavior.skipCollapsed = true + dialog.behavior.isDraggable = !userPreferences.lockedDrawers + // Fix for https://github.com/Slion/Magic/issues/226 + dialog.behavior.maxWidth = -1 // We want fullscreen width + + // Make sure dialog top padding and status bar icons color are updated whenever our dialog is resized + // Since we keep recreating our dialogs every time we open them we should not accumulate observers here + (aContentView.parent as View).onSizeChange { + // This is designed so that callbacks are cancelled unless our timeout expires + // That avoids spamming adjustBottomSheet while our view is animated or dragged + mainHandler.removeCallbacks(onSizeChangeRunnable) + onSizeChangeRunnable = Runnable { + // Catch and ignore exceptions as adjustBottomSheet is using reflection to call private methods. + // Jamal was reporting this was not working on his device for some reason. + try { + // Also I'm not sure now why we needed that, maybe it has since been fixed in the material components library. + // Though the GitHub issue specified in that function description is still open. + adjustBottomSheet(dialog) + } catch (ex: java.lang.Exception) { + Timber.e(ex, "adjustBottomSheet failed") + } + } + mainHandler.postDelayed(onSizeChangeRunnable, 100) + } + + return dialog; + } + + + /** + * + */ + private fun createTabsDialog() + { + tabsDialog.dismiss() // Defensive + // Workaround issue with black icons during transition after first use + // See: https://github.com/material-components/material-components-android/issues/2168 + tabsDialog = createBottomSheetDialog(tabsView as View) + // Once our bottom sheet is open we want it to scroll to current tab + tabsDialog.setOnShowListener { tryScrollToCurrentTab() } + /* + tabsDialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + // State change is not called if we don't recreate our dialog or actually change the state, which makes sense + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + mainHandler.postDelayed({scrollToCurrentTab()},1000) + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } + } + )*/ + + } + + /** + * + */ + private fun createBookmarksDialog() + { + bookmarksDialog.dismiss() // Defensive + // Workaround issue with black icons during transition after first use. + // See: https://github.com/material-components/material-components-android/issues/2168 + bookmarksDialog = createBottomSheetDialog(bookmarksView as View) + + } + + /** + * Open our sessions pop-up menu. + */ + private fun showSessions() { + // If using horizontal tab bar + // or if our bottom sheet dialog is opened + // or if using vertical embedded tab bar + // or drawer is opened, assuming tab drawer since that's the only one with sessions button + if (tabsView is TabsDesktopView || tabsDialog.isShowing || !tabBarInDrawer || drawerOpened) { + // Use on screen session button as anchor + buttonSessions.let { iMenuSessions.show(it) } + } + else { + // Otherwise use main menu button as anchor + // Certainly calling sessions dialog from main menu option + iBindingToolbarContent.buttonMore.let { iMenuSessions.show(it) } + } + } + + // Set whenever a menu was just closed + private var iJustClosedMenu = false + + /** + * Used to avoid processing back commands when we just closed a menu. + * That's done to improve user experience when user is using full screen gesture. + * As user does its full screen back gesture it in fact dismisses the menu just by touching the screen outside the menu. + * That meant once the back gesture was completed it reached the activity which was processing it as if the menu were not there. + * In the end it looked like two back keys were processed. + */ + private fun justClosedMenuCountdown() { + iJustClosedMenu = true; mainHandler.postDelayed({iJustClosedMenu = false},250) + } + + /** + * + */ + private fun createMenuMain() { + iMenuMain = MenuMain(layoutInflater) + // TODO: could use data binding instead + iMenuMain.apply { + // Menu + onMenuItemClicked(iBinding.menuItemWebPage) { dismiss(); showMenuWebPage() } + // Bind our actions + onMenuItemClicked(iBinding.menuItemSessions) { dismiss(); executeAction(R.id.action_sessions) } + onMenuItemClicked(iBinding.menuItemNewTab) { dismiss(); executeAction(R.id.action_new_tab) } + onMenuItemClicked(iBinding.menuItemIncognito) { dismiss(); executeAction(R.id.action_incognito) } + onMenuItemClicked(iBinding.menuItemHistory) { dismiss(); executeAction(R.id.action_history) } + onMenuItemClicked(iBinding.menuItemDownloads) { dismiss(); executeAction(R.id.action_downloads) } + onMenuItemClicked(iBinding.menuItemBookmarks) { dismiss(); executeAction(R.id.action_bookmarks) } + onMenuItemClicked(iBinding.menuItemExit) { dismiss(); executeAction(R.id.action_exit) } + // + onMenuItemClicked(iBinding.menuItemSettings) { dismiss(); executeAction(R.id.action_settings) } + onMenuItemClicked(iBinding.menuItemOptions) { + dismiss() + app.domain = currentHost() + iBottomSheet.setLayout(R.layout.fragment_settings_options).show() + } + + // Popup menu action shortcut icons + onMenuItemClicked(iBinding.menuShortcutRefresh) { dismiss(); executeAction(R.id.action_reload) } + onMenuItemClicked(iBinding.menuShortcutHome) { dismiss(); executeAction(R.id.action_show_homepage) } + onMenuItemClicked(iBinding.menuShortcutBookmarks) { dismiss(); executeAction(R.id.action_bookmarks) } + // Back and forward do not dismiss the menu to make it easier for users to navigate tab history + onMenuItemClicked(iBinding.menuShortcutForward) { iBinding.layoutMenuItemsContainer.isVisible=false; executeAction(R.id.action_forward) } + onMenuItemClicked(iBinding.menuShortcutBack) { iBinding.layoutMenuItemsContainer.isVisible=false; executeAction(R.id.action_back) } + + // Make it full screen gesture friendly + setOnDismissListener { justClosedMenuCountdown() } + } + } + + /** + * Show settings for the provided domain + */ + fun showDomainSettings(aDomain: String) { + app.domain = aDomain + iBottomSheet.setLayout(R.layout.fragment_settings_domain).show() + } + + /** + * + */ + private fun showMenuMain() { + // Hide web page menu + iMenuWebPage.dismiss() + // Web page is loosing focus as we open our menu + // Should notably hide the virtual keyboard + currentTabView?.clearFocus() + searchView.clearFocus() + // Show popup menu once our virtual keyboard is hidden + doOnceVirtualKeyboardIsGone { doShowMenuMain() } + } + + /** + * + */ + private fun doShowMenuMain() { + // Make sure back and forward buttons are in correct state + setForwardButtonEnabled(tabsManager.currentTab?.canGoForward()?:false) + setBackButtonEnabled(tabsManager.currentTab?.canGoBack()?:false) + // Open our menu + iMenuMain.show(iBindingToolbarContent.buttonMore) + } + + /** + * + */ + private fun createMenuWebPage() { + iMenuWebPage = MenuWebPage(layoutInflater) + // TODO: could use data binding instead + iMenuWebPage.apply { + onMenuItemClicked(iBinding.menuItemMainMenu) { dismiss(); doShowMenuMain() } + // Web page actions + onMenuItemClicked(iBinding.menuItemPageHistory) { + dismiss() + iBottomSheet.setLayout(R.layout.fragment_settings_page_history).show() + } + onMenuItemClicked(iBinding.menuItemShare) { dismiss(); executeAction(R.id.action_share) } + onMenuItemClicked(iBinding.menuItemAddBookmark) { dismiss(); executeAction(R.id.action_add_bookmark) } + onMenuItemClicked(iBinding.menuItemFind) { dismiss(); executeAction(R.id.action_find) } + onMenuItemClicked(iBinding.menuItemPrint) { dismiss(); executeAction(R.id.action_print) } + onMenuItemClicked(iBinding.menuItemAddToHome) { dismiss(); executeAction(R.id.action_add_to_homescreen) } + onMenuItemClicked(iBinding.menuItemReaderMode) { dismiss(); executeAction(R.id.action_reading_mode) } + onMenuItemClicked(iBinding.menuItemDesktopMode) { dismiss(); executeAction(R.id.action_toggle_desktop_mode) } + onMenuItemClicked(iBinding.menuItemDarkMode) { dismiss(); executeAction(R.id.action_toggle_dark_mode) } + onMenuItemClicked(iBinding.menuItemAdBlock) { dismiss(); executeAction(R.id.action_block) } + onMenuItemClicked(iBinding.menuItemTranslate) { dismiss(); executeAction(R.id.action_translate) } + // Popup menu action shortcut icons + onMenuItemClicked(iBinding.menuShortcutRefresh) { dismiss(); executeAction(R.id.action_reload) } + onMenuItemClicked(iBinding.menuShortcutHome) { dismiss(); executeAction(R.id.action_show_homepage) } + // Back and forward do not dismiss the menu to make it easier for users to navigate tab history + onMenuItemClicked(iBinding.menuShortcutForward) { iBinding.layoutMenuItemsContainer.isVisible=false; executeAction(R.id.action_forward) } + onMenuItemClicked(iBinding.menuShortcutBack) { iBinding.layoutMenuItemsContainer.isVisible=false; executeAction(R.id.action_back) } + //onMenuItemClicked(iBinding.menuShortcutBookmarks) { executeAction(R.id.action_bookmarks) } + + // Make it full screen gesture friendly + setOnDismissListener { justClosedMenuCountdown() } + } + } + + /** + * + */ + private fun showMenuWebPage() { + // Hide main menu + iMenuMain.dismiss() + // Web page is loosing focus as we open our menu + // Should notably hide the virtual keyboard + currentTabView?.clearFocus() + searchView.clearFocus() + // Show popup menu once our virtual keyboard is hidden + doOnceVirtualKeyboardIsGone { doShowMenuWebPage() } + } + + + /** + * + */ + private fun doShowMenuWebPage() { + iMenuWebPage.show(iBindingToolbarContent.buttonMore) + } + + + /** + * Needed to be able to display system notifications + */ + private fun createNotificationChannel() { + // Is that string visible in system UI somehow? + CHANNEL_ID = "Magic Channel ID" + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = getString(R.string.downloads) + val descriptionText = getString(R.string.downloads_notification_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + } + // Register the channel with the system + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Provide primary color, typically used as default toolbar color. + */ + private val primaryColor: Int + get() { + // If current tab is using forced dark mode and we do not use a dark theme… + return if (tabsManager.currentTab?.darkMode == true && !isDarkTheme()) { + // …then override primary color… + Color.BLACK + } else { + // …otherwise just use current theme surface color. + ThemeUtils.getSurfaceColor(this) + } + } + + + /** + * See below. + */ + private val iDisableFabs : Runnable = Runnable { + iBinding.fabInclude.fabContainer.isVisible = false + tabSwitchStop() + } + + // Used to manage our Easy Tab Switcher + private var iTabsButtonLongPressed = false + private var iEasyTabSwitcherWasUsed = false + + /** + * Will disable floating action buttons once our countdown expires + */ + private fun restartDisableFabsCountdown() { + if (!iTabsButtonLongPressed) { + // Cancel any pending action if any + cancelDisableFabsCountdown() + // Restart our countdown + // TODO: make that delay a settings option? + mainHandler.postDelayed(iDisableFabs, 5000) + } + } + + /** + * + */ + private fun cancelDisableFabsCountdown() { + mainHandler.removeCallbacks(iDisableFabs) + } + + /** + * Maximum distance touch event can travel on the off axis before we abort swipe gesture. + */ + val kMaxSwipeDistance = 40.px + + /** + * Minimum distance touch event must travel before we register swipe gesture in DP + */ + val kMinSwipeDistance = 60.px + + /** + * Maximum duration of a swipe gesture + */ + val kMaxSwipeTime = 800 + + /** + * + */ + private fun createToolbar() { + // Create our toolbar and hook it to its parent + iBindingToolbarContent = ToolbarContentBinding.inflate(layoutInflater, iBinding.toolbarInclude.toolbar, true) + + // Create a gesture detector to catch horizontal swipes our on toolbar + val toolbarSwipeDetector = GestureDetectorCompat(this, object : GestureDetector.SimpleOnGestureListener() { + + override fun onFling(event1: MotionEvent?, event2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + + if (event1==null) { + return false + } + + // No swipe action when our text field is focused + if (searchView.hasFocus()) { + return false + } + + // No swipe when too long, that allows scrolling page title text for instance + if ((event2.eventTime - event1.eventTime) > kMaxSwipeTime) { + return false + } + + //Timber.d("onFling: $event1 $event2") + val dX = abs(event1.x - event2.x) + val dY = abs(event1.y - event2.y) + Timber.d("onFling toolbar: $velocityX ; $velocityY : $dX ; $dY : $kMinSwipeDistance ; $kMaxSwipeDistance") + if (dX > kMinSwipeDistance && dY < kMaxSwipeDistance) { + if (velocityX < 0) { + // Swipe left + if (!tabSwitchInProgress()) { + easyTabSwitcherStart() + } + easyTabSwitcherBack() + // Needed otherwise the text field can gain focus + return true + + } else { + // Swipe right + if (!tabSwitchInProgress()) { + easyTabSwitcherStart() + } + easyTabSwitcherForward() + // Needed otherwise the text field can gain focus + return true + } + } + return false + } + }) + + // Hook in our gesture detector + iBinding.toolbarInclude.toolbar.setOnTouchInterceptor { v, event -> + if (toolbarSwipeDetector.onTouchEvent(event)) { + v.performClick() + return@setOnTouchInterceptor true + } + false + } + } + + /** + * + */ + @SuppressLint("ClickableViewAccessibility") + private fun initialize(savedInstanceState: Bundle?) { + + createNotificationChannel() + + createToolbar() + + // TODO: disable those for incognito mode? + analytics = userPreferences.analytics + crashReport = userPreferences.crashReport + showCloseTabButton = userPreferences.showCloseTabButton + + if (!isIncognito()) { + // For some reason that was crashing when incognito + // I'm guessing somehow that's already disabled when incognito + setAnalyticsCollectionEnabled(this, userPreferences.analytics) + setCrashlyticsCollectionEnabled(userPreferences.crashReport) + } + + swapBookmarksAndTabs = userPreferences.bookmarksAndTabsSwapped + + // initialize background ColorDrawable + backgroundDrawable.color = primaryColor + + // Drawer stutters otherwise + //left_drawer.setLayerType(LAYER_TYPE_NONE, null) + //right_drawer.setLayerType(LAYER_TYPE_NONE, null) + + + iBinding.drawerLayout.addDrawerListener(DrawerLocker()) + + + // Show incognito icon in more menu button + if (isIncognito()) { + iBindingToolbarContent.buttonMore.setImageResource(R.drawable.ic_incognito) + } + + // Is that still needed + val customView = iBinding.toolbarInclude.toolbar + customView.layoutParams = customView.layoutParams.apply { + width = LayoutParams.MATCH_PARENT + height = LayoutParams.MATCH_PARENT + } + + // Define tabs button clicks handlers + iBindingToolbarContent.tabsButton.setOnClickListener(this) + iBindingToolbarContent.tabsButton.setOnLongClickListener { view -> + iTabsButtonLongPressed = true + easyTabSwitcherStart() + // We still want tooltip to show so return false here + false + } + + // Handle release of tabs button after long press + iBindingToolbarContent.tabsButton.setOnTouchListener{ v, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> {} + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (iTabsButtonLongPressed) { + iTabsButtonLongPressed = false + if (iEasyTabSwitcherWasUsed) { + // Tabs button was released after using tab switcher + // User was using multiple fingers + // Hide fabs on the spot to emulate CTRL+TAB best + iDisableFabs.run() + } else { + // Tabs button was released without using tab switcher + // Give a chance to the user to use it with a single finger + // Only hide fabs after countdown + restartDisableFabsCountdown() + } + } + } + } + false + } + + // Close current tab during tab switch + // TODO: What if a tab is opened during tab switch? + iBinding.fabInclude.fabTabClose.setOnClickListener { + iEasyTabSwitcherWasUsed = true + restartDisableFabsCountdown() + tabsManager.let { tabsManager.deleteTab(it.indexOfCurrentTab()) } + tabSwitchReset() + } + + // Switch back in our tab list + iBinding.fabInclude.fabBack.setOnClickListener { + easyTabSwitcherBack() + } + + // Switch forward in our tab list + iBinding.fabInclude.fabForward.setOnClickListener{ + easyTabSwitcherForward() + } + + + iBindingToolbarContent.homeButton.setOnClickListener(this) + iBindingToolbarContent.buttonActionBack.setOnClickListener{executeAction(R.id.action_back)} + iBindingToolbarContent.buttonActionForward.setOnClickListener{executeAction(R.id.action_forward)} + + //setFullscreenIfNeeded(resources.configuration) // As that's needed before bottom sheets creation + createTabsView() + //createTabsDialog() + bookmarksView = BookmarksDrawerView(this) + //createBookmarksDialog() + + // create the search EditText in the ToolBar + searchView = iBindingToolbarContent.addressBarInclude.search.apply { + iBindingToolbarContent.addressBarInclude.searchSslStatus.setOnClickListener { + tabsManager.currentTab?.let { tab -> + tab.sslCertificate?.let { showSslDialog(it, tab.currentSslState()) } + } + } + iBindingToolbarContent.addressBarInclude.searchSslStatus.updateVisibilityForContent() + //setMenuItemIcon(R.id.action_reload, R.drawable.ic_action_refresh) + //toolbar?.menu?.findItem(R.id.action_reload)?.let { it.icon = ContextCompat.getDrawable(this@BrowserActivity, R.drawable.ic_action_refresh) } + + val searchListener = SearchListenerClass() + setOnKeyListener(searchListener) + onFocusChangeListener = searchListener + setOnEditorActionListener(searchListener) + onPreFocusListener = searchListener + addTextChangedListener(StyleRemovingTextWatcher()) + + initializeSearchSuggestions(this) + } + + // initialize search background color + setSearchBarColors(primaryColor) + + var intent: Intent? = if (savedInstanceState == null) { + intent + } else { + null + } + + val launchedFromHistory = intent != null && intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + + if (intent?.action == INTENT_PANIC_TRIGGER) { + setIntent(null) + panicClean() + } else { + if (launchedFromHistory) { + intent = null + } + // Load our tabs + // TODO: Consider not reloading our session if it is already loaded. + // That could be the case notably when the activity is restarted after theme change in settings + // However that would require we careful setup our UI anew from an already loaded session + tabsManager.setupTabs(intent) + setIntent(null) + proxyUtils.checkForProxy(this) + } + + // Enable swipe to refresh + iTabViewContainerFront.setOnRefreshListener { + tabsManager.currentTab?.reload() + mainHandler.postDelayed({ iTabViewContainerFront.isRefreshing = false }, 1000) // Stop the loading spinner after one second + } + + iTabViewContainerBack.setOnRefreshListener { + tabsManager.currentTab?.reload() + // Assuming this guys will be in front when refreshing + mainHandler.postDelayed({ iTabViewContainerFront.isRefreshing = false }, 1000) // Stop the loading spinner after one second + } + // TODO: define custom transitions to make flying in and out of the tool bar nicer + //ui_layout.layoutTransition.setAnimator(LayoutTransition.DISAPPEARING, ui_layout.layoutTransition.getAnimator(LayoutTransition.CHANGE_DISAPPEARING)) + // Disabling animations which are not so nice + iBinding.uiLayout.layoutTransition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING) + iBinding.uiLayout.layoutTransition.disableTransitionType(LayoutTransition.CHANGE_APPEARING) + + + setupButtonMore() + } + + /** + * + */ + @SuppressLint("ClickableViewAccessibility") + private fun setupButtonMore() { + + iBindingToolbarContent.buttonMore.setOnClickListener { + // Without that handler we don't get audio feedback on F(x)tec Pro¹ + } + + val menuSwipeDetector = GestureDetectorCompat(this, object : GestureDetector.SimpleOnGestureListener() { + + override fun onDoubleTapEvent(e: MotionEvent): Boolean { + Timber.d("onDoubleTapEvent menu") + showMenuWebPage() + return true + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + Timber.d("onSingleTapUp menu") + showMenuMain() + return true + } + +// override fun onSingleTapConfirmed(e: MotionEvent): Boolean { +// Timber.d("onSingleTapConfirmed menu") +// showMenuMain() +// return true +// } + + override fun onFling(event1: MotionEvent?, event2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + + if (event1==null) { + return false + } + + // No swipe when too long + if ((event2.eventTime - event1.eventTime) > kMaxSwipeTime) { + return false + } + //Timber.d("onFling: $event1 $event2") + val dX = abs(event1.x - event2.x) + val dY = abs(event1.y - event2.y) + Timber.d("onFling menu: $velocityX ; $velocityY : $dX ; $dY : $kMinSwipeDistance ; $kMaxSwipeDistance") + if (dY > kMinSwipeDistance && dX < kMaxSwipeDistance) { + showMenuWebPage() + return true + } + return false + } + }) + + iBindingToolbarContent.buttonMore.setOnTouchListener { v, event -> + if (menuSwipeDetector.onTouchEvent(event)) { + v.performClick() + // Set focus to menu button + v.requestFocus() + return@setOnTouchListener true + } + false + } + } + + /** + * + */ + private fun easyTabSwitcherStart() { + iBinding.fabInclude.fabContainer.isVisible = true + iEasyTabSwitcherWasUsed = false + cancelDisableFabsCountdown() + tabSwitchStart() + } + + /** + * + */ + private fun easyTabSwitcherBack() { + iEasyTabSwitcherWasUsed = true + restartDisableFabsCountdown() + tabSwitchBack() + tabSwitchApply(true) + } + + /** + * + */ + private fun easyTabSwitcherForward() { + iEasyTabSwitcherWasUsed = true + restartDisableFabsCountdown() + tabSwitchForward() + tabSwitchApply(false) + } + + // Make sure we will show our popup menu at some point + private var iPopupMenuTries: Int = 0 + private val kMaxPopupMenuTries: Int = 5 + + /** + * Show popup menu once our virtual keyboard is hidden. + * This was designed so that popup menu does not remain in the middle of the screen once virtual keyboard is hidden, + * notably when using toolbars at the bottom option. + */ + private fun doOnceVirtualKeyboardIsGone(runnable: Runnable) { + // Check if virtual keyboard is showing and if we have another try to wait for it to close + if (inputMethodManager.isVirtualKeyboardVisible() && iPopupMenuTries(R.id.tabs_list) != v.findViewById(R.id.tabs_list)) { + // It was not found, just put it there then + v.removeFromParent() + tabsDialog.setContentView(v) + } + } else { + // Check if our tab view is already in place + if (v.parent != getTabBarContainer()) { + // It was not, lets put it there then + v.removeFromParent() + getTabBarContainer().addView(v) + } + } + } + + private fun getBookmarksContainer(): ViewGroup = if (swapBookmarksAndTabs) { + iBinding.leftDrawerContent + } else { + iBinding.rightDrawerContent + } + + /** + * + */ + private fun getTabBarContainer(): ViewGroup = + if (verticalTabBar) { + if (swapBookmarksAndTabs) { + if (tabBarInDrawer) { + iBinding.rightDrawerContent + } else { + iBinding.layoutTabsRight + } + } else { + if (tabBarInDrawer) { + iBinding.leftDrawerContent + } else { + iBinding.layoutTabsLeft + } + } + } else { + iBinding.toolbarInclude.tabBarContainer + } + + private fun getBookmarkDrawer(): View = if (swapBookmarksAndTabs) { + iBinding.leftDrawer + } else { + iBinding.rightDrawer + } + + private fun getTabDrawer(): View = if (swapBookmarksAndTabs) { + iBinding.rightDrawer + } else { + iBinding.leftDrawer + } + + protected fun panicClean() { + Timber.d("Closing browser") + tabsManager.newTab(this, NoOpInitializer(), false, NewTabPosition.END_OF_TAB_LIST) + tabsManager.switchToTab(0) + tabsManager.clearSavedState() + + historyPageFactory.deleteHistoryPage().subscribe() + closeBrowser() + // System exit needed in the case of receiving + // the panic intent since finish() isn't completely + // closing the browser + exitProcess(1) + } + + private inner class SearchListenerClass : View.OnKeyListener, + OnEditorActionListener, + View.OnFocusChangeListener, + SearchView.PreFocusListener { + + override fun onKey(view: View, keyCode: Int, keyEvent: KeyEvent): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_ENTER -> { + searchView.let { + if (it.listSelection == ListView.INVALID_POSITION) { + // No suggestion pop up item selected, just trigger a search then + inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) + searchTheWeb(it.text.toString()) + } else { + // An item in our selection pop up is selected, just action it + doSearchSuggestionAction(it, it.listSelection) + } + } + tabsManager.currentTab?.requestFocus() + return true + } + else -> { + } + } + return false + } + + override fun onEditorAction(arg0: TextView, actionId: Int, arg2: KeyEvent?): Boolean { + // hide the keyboard and search the web when the enter key + // button is pressed + if (actionId == EditorInfo.IME_ACTION_GO + || actionId == EditorInfo.IME_ACTION_DONE + || actionId == EditorInfo.IME_ACTION_NEXT + || actionId == EditorInfo.IME_ACTION_SEND + || actionId == EditorInfo.IME_ACTION_SEARCH + || arg2?.action == KeyEvent.KEYCODE_ENTER) { + searchView.let { + inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) + searchTheWeb(it.text.toString()) + } + + tabsManager.currentTab?.requestFocus() + return true + } + return false + } + + override fun onFocusChange(v: View, hasFocus: Boolean) { + val currentView = tabsManager.currentTab + + if (currentView != null) { + setIsLoading(currentView.progress < 100) + + if (!hasFocus) { + updateUrl(currentView.url, false) + } else if (hasFocus) { + showUrl() + // Select all text so that user conveniently start typing or copy current URL + (v as SearchView).selectAll() + iBindingToolbarContent.addressBarInclude.searchSslStatus.visibility = GONE + } + } + + if (!hasFocus) { + iBindingToolbarContent.addressBarInclude.searchSslStatus.updateVisibilityForContent() + searchView.let { + inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) + } + } + } + + override fun onPreFocus() { + // SL: hopefully not needed anymore + // That was never working with keyboard + //val currentView = tabsManager.currentTab ?: return + //val url = currentView.url + //if (!url.isSpecialUrl()) { + // if (searchView.hasFocus() == false) { + // searchView.setText(url) + // } + //} + } + } + + /** + * + */ + private fun currentUrl() : String { + val currentView = tabsManager.currentTab ?: return "" + return currentView.url + } + + /** + * + */ + private fun currentHost(): String { + return Uri.parse(currentUrl()).host.toString() + } + + + + /** + * Called when search view gains focus + */ + private fun showUrl() { + val currentView = tabsManager.currentTab ?: return + val url = currentView.url + if (!url.isSpecialUrl()) { + searchView.setText(url) + } + else { + // Special URLs like home page and history just show search field then + searchView.setText("") + } + } + + var drawerOpened : Boolean = false + var drawerOpening : Boolean = false + var drawerClosing : Boolean = false + + private inner class DrawerLocker : DrawerLayout.DrawerListener { + + override fun onDrawerClosed(v: View) { + drawerOpened = false + drawerClosing = false + drawerOpening = false + + // Trying to sort out our issue with touch input reaching through drawer into address bar + //toolbar_layout.isEnabled = true + + // This was causing focus problems when switching directly from tabs drawer to bookmarks drawer + //currentTabView?.requestFocus() + + if (userPreferences.lockedDrawers) return; // Drawers remain locked + val tabsDrawer = getTabDrawer() + val bookmarksDrawer = getBookmarkDrawer() + + if (v === tabsDrawer) { + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, bookmarksDrawer) + } else if (verticalTabBar) { + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, tabsDrawer) + } + + } + + override fun onDrawerOpened(v: View) { + + drawerOpened = true + drawerClosing = false + drawerOpening = false + + // Trying to sort out our issue with touch input reaching through drawer into address bar + //toolbar_layout.isEnabled = false + + if (userPreferences.lockedDrawers) return; // Drawers remain locked + + val tabsDrawer = getTabDrawer() + val bookmarksDrawer = getBookmarkDrawer() + + if (v === tabsDrawer) { + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, bookmarksDrawer) + } else { + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, tabsDrawer) + } + } + + override fun onDrawerSlide(v: View, arg: Float) = Unit + + override fun onDrawerStateChanged(arg: Int) { + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + // Make sure status bar icons have the proper color set when we start opening and closing a drawer + // We set status bar icon color according to current theme + if (arg == ViewDragHelper.STATE_SETTLING) { + if (!drawerOpened) { + drawerOpening = true + // Make sure icons on status bar remain visible + // We should really check the primary theme color and work out its luminance but that should do for now + window.setStatusBarIconsColor(!isDarkTheme() && !userPreferences.useBlackStatusBar) + } + else { + drawerClosing = true + // Restore previous system UI visibility flag + setToolbarColor() + } + } + } + } + + + private fun lockDrawers() + { + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, getTabDrawer()) + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, getBookmarkDrawer()) + } + + private fun unlockDrawers() + { + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, getTabDrawer()) + iBinding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, getBookmarkDrawer()) + } + + + /** + * Set toolbar color corresponding to the current tab + */ + private fun setToolbarColor() + { + val currentView = tabsManager.currentTab + if (isColorMode() && currentView != null && currentView.htmlMetaThemeColor!=Color.TRANSPARENT && !currentView.darkMode) { + // Web page does specify theme color, use it much like Google Chrome does + mainHandler.post {applyToolbarColor(currentView.htmlMetaThemeColor)} + } + else if (isColorMode() && currentView?.favicon != null && !currentView.darkMode) { + // Web page has favicon, use it to extract page theme color + changeToolbarBackground(currentView.favicon, Color.TRANSPARENT, null) + } else { + // That should be the primary color from current theme + mainHandler.post {applyToolbarColor(primaryColor)} + } + } + + /** + * + */ + private fun initFullScreen() { + isFullScreen = configPrefs.hideToolBar + } + + + private var wasToolbarsBottom = false; + + /** + * Setup our tool bar as collapsible or always-on according to orientation and user preferences. + * Also manipulate our layout according to toolbars-at-bottom user preferences. + * + * TODO: Configuration parameter should not be needed. Remove it at some point + */ + private fun setupToolBar(configuration: Configuration) { + initFullScreen() + initializeToolbarHeight(configuration) + showActionBar() + setToolbarColor() + setFullscreenIfNeeded() + + // Put our toolbar where it belongs, top or bottom according to user preferences + iBinding.toolbarInclude.apply { + if (configPrefs.toolbarsBottom) { + + // Move search bar to the bottom + iBinding.findInPageInclude.root.let { + it.removeFromParent()?.addView(it) + } + + // Move toolbar to the bottom + root.removeFromParent()?.addView(root) + + // Rearrange it so that it is upside down + // Put tab bar at the bottom + tabBarContainer.removeFromParent()?.addView(tabBarContainer) + // Put progress bar at the top + progressView.removeFromParent()?.addView(progressView, 0) + // Take care of tab drawer if any + (tabsView as? TabsDrawerView)?.apply { + // Put our tab list on top then to push toolbar to the bottom + iBinding.tabsList.removeFromParent()?.addView(iBinding.tabsList, 0) + // Use reversed layout from bottom to top + (iBinding.tabsList.layoutManager as? LinearLayoutManager)?.apply { + reverseLayout = true + // Fix broken scroll to item + //stackFromEnd = true + } + } + + // Take care of bookmarks drawer + (bookmarksView as? BookmarksDrawerView)?.apply { + // Put our list on top then to push toolbar to the bottom + iBinding.listBookmarks.removeFromParent()?.addView(iBinding.listBookmarks, 0) + // Use reversed layout from bottom to top + (iBinding.listBookmarks.layoutManager as? LinearLayoutManager)?.reverseLayout = true + } + + // Deal with session menu + if (configPrefs.verticalTabBar && !configPrefs.tabBarInDrawer) { + iMenuSessions.animationStyle = R.style.AnimationMenuDesktopBottom + } else { + iMenuSessions.animationStyle = R.style.AnimationMenuBottom + } + (iMenuSessions.iBinding.recyclerViewSessions.layoutManager as? LinearLayoutManager)?.apply { + reverseLayout = true + stackFromEnd = true + } + // Move sessions menu toolbar to the bottom + iMenuSessions.iBinding.toolbar.apply{removeFromParent()?.addView(this)} + + // Set popup menus animations + iMenuMain.animationStyle = R.style.AnimationMenuBottom + // Move popup menu toolbar to the bottom + iMenuMain.iBinding.header.apply{removeFromParent()?.addView(this)} + // Move items above our toolbar separator + iMenuMain.iBinding.scrollViewItems.apply{removeFromParent()?.addView(this, 0)} + // Reverse menu items if needed + if (!wasToolbarsBottom) { + val children = iMenuMain.iBinding.layoutMenuItems.children.toList() + children.reversed().forEach { item -> item.removeFromParent()?.addView(item) } + } + + // Set popup menus animations + iMenuWebPage.animationStyle = R.style.AnimationMenuBottom + // Move popup menu toolbar to the bottom + iMenuWebPage.iBinding.header.apply{removeFromParent()?.addView(this)} + // Move items above our toolbar separator + iMenuWebPage.iBinding.scrollViewItems.apply{removeFromParent()?.addView(this, 0)} + // Reverse menu items if needed + if (!wasToolbarsBottom) { + val children = iMenuWebPage.iBinding.layoutMenuItems.children.toList() + children.reversed().forEach { item -> item.removeFromParent()?.addView(item) } + } + + // Set search dropdown anchor to avoid gap + searchView.dropDownAnchor = R.id.address_bar_include + + // Floating Action Buttons at the bottom + iBinding.fabInclude.fabContainer.apply {setGravityBottom(layoutParams as CoordinatorLayout.LayoutParams)} + + // FAB tab close button at the bottom + iBinding.fabInclude.fabTabClose.apply {setGravityBottom(layoutParams as LinearLayout.LayoutParams)} + + // ctrlTabBack at the top + iBinding.fabInclude.fabBack.apply{removeFromParent()?.addView(this, 0)} + } else { + // Move search in page to top + iBinding.findInPageInclude.root.let { + it.removeFromParent()?.addView(it, 0) + } + // Move toolbar to the top + root.removeFromParent()?.addView(root, 0) + //iBinding.uiLayout.addView(root, 0) + // Rearrange it so that it is the right way up + // Put tab bar at the bottom + tabBarContainer.removeFromParent()?.addView(tabBarContainer, 0) + // Put progress bar at the top + progressView.removeFromParent()?.addView(progressView) + // Take care of tab drawer if any + (tabsView as? TabsDrawerView)?.apply { + // Put our tab list at the bottom + iBinding.tabsList.removeFromParent()?.addView(iBinding.tabsList) + // Use straight layout from top to bottom + (iBinding.tabsList.layoutManager as? LinearLayoutManager)?.apply { + reverseLayout = false + //stackFromEnd = false + } + // We don't need that spacer now + //iBinding.tabsListSpacer.isVisible = false + } + + // Take care of bookmarks drawer + (bookmarksView as? BookmarksDrawerView)?.apply { + // Put our list at the bottom + iBinding.listBookmarks.removeFromParent()?.addView(iBinding.listBookmarks) + // Use reversed layout from bottom to top + (iBinding.listBookmarks.layoutManager as? LinearLayoutManager)?.reverseLayout = false + } + + // Deal with session menu + if (configPrefs.verticalTabBar && !configPrefs.tabBarInDrawer) { + iMenuSessions.animationStyle = R.style.AnimationMenuDesktopTop + } else { + iMenuSessions.animationStyle = R.style.AnimationMenu + } + (iMenuSessions.iBinding.recyclerViewSessions.layoutManager as? LinearLayoutManager)?.apply { + reverseLayout = false + stackFromEnd = false + } + // Move sessions menu toolbar to the top + iMenuSessions.iBinding.toolbar.apply{removeFromParent()?.addView(this, 0)} + + // Set popup menus animations + iMenuMain.animationStyle = R.style.AnimationMenu + // Move popup menu toolbar to the top + iMenuMain.iBinding.header.apply{removeFromParent()?.addView(this, 0)} + // Move items below our toolbar separator + iMenuMain.iBinding.scrollViewItems.apply{removeFromParent()?.addView(this)} + // Reverse menu items if needed + if (wasToolbarsBottom) { + val children = iMenuMain.iBinding.layoutMenuItems.children.toList() + children.reversed().forEach { item -> item.removeFromParent()?.addView(item) } + } + + // Set popup menus animations + iMenuWebPage.animationStyle = R.style.AnimationMenu + // Move popup menu toolbar to the top + iMenuWebPage.iBinding.header.apply{removeFromParent()?.addView(this, 0)} + // Move items below our toolbar separator + iMenuWebPage.iBinding.scrollViewItems.apply{removeFromParent()?.addView(this)} + // Reverse menu items if needed + if (wasToolbarsBottom) { + val children = iMenuWebPage.iBinding.layoutMenuItems.children.toList() + children.reversed().forEach { item -> item.removeFromParent()?.addView(item) } + } + + // Set search dropdown anchor to avoid gap + searchView.dropDownAnchor = R.id.toolbar_include + + // Floating Action Buttons at the top + iBinding.fabInclude.fabContainer.apply {setGravityTop(layoutParams as CoordinatorLayout.LayoutParams)} + + // FAB tab close button at the bottom + iBinding.fabInclude.fabTabClose.apply {setGravityTop(layoutParams as LinearLayout.LayoutParams)} + + // ctrlTabBack at the bottom + iBinding.fabInclude.fabBack.apply{removeFromParent()?.addView(this)} + } + } + + wasToolbarsBottom = configPrefs.toolbarsBottom + } + + /** + * + */ + private fun setupToolBar() { + // Check if our tool bar is long enough to display extra buttons + val threshold = (iBindingToolbarContent.buttonActionBack.width?:3840)*10 + // If our tool bar is longer than 10 action buttons then we show extra buttons + (iBinding.toolbarInclude.toolbar.width>threshold).let{ + iBindingToolbarContent.buttonActionBack.isVisible = it + iBindingToolbarContent.buttonActionForward.isVisible = it + // Hide tab bar action buttons if no room for them + if (tabsView is TabsDesktopView) { + (tabsView as TabsDesktopView).iBinding.actionButtons.isVisible = it + } + } + + } + + private fun initializePreferences() { + + // TODO layout transition causing memory leak + // iBinding.contentFrame.setLayoutTransition(new LayoutTransition()); + + val currentSearchEngine = searchEngineProvider.provideSearchEngine() + searchText = currentSearchEngine.queryUrl + + updateCookiePreference().subscribeOn(diskScheduler).subscribe() + proxyUtils.updateProxySettings(this) + } + + public override fun onWindowVisibleToUserAfterResume() { + super.onWindowVisibleToUserAfterResume() + } + + fun actionFocusTextField() { + if (!isToolBarVisible()) { + showActionBar() + } + searchView.requestFocus() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (searchView.hasFocus() == true) { + searchView.let { searchTheWeb(it.text.toString()) } + } + } + else if (keyCode == KeyEvent.KEYCODE_BACK) { + keyDownStartTime = System.currentTimeMillis() + mainHandler.postDelayed(longPressBackRunnable, ViewConfiguration.getLongPressTimeout().toLong()) + } + return super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + mainHandler.removeCallbacks(longPressBackRunnable) + if (System.currentTimeMillis() - keyDownStartTime > ViewConfiguration.getLongPressTimeout()) { + return true + } + } + return super.onKeyUp(keyCode, event) + } + + // For CTRL+TAB implementation + private var iRecentTabIndex = -1; + private var iCapturedRecentTabsIndices : Set? = null + + private fun tabSwitchInProgress() = iRecentTabIndex!=-1 + + private fun copyRecentTabsList() + { + // Fetch snapshot of our recent tab list + iCapturedRecentTabsIndices = tabsManager.iRecentTabs.toSet() + iRecentTabIndex = iCapturedRecentTabsIndices?.size?.minus(1) ?: -1 + //Timber.d("Recent indices snapshot: iCapturedRecentTabsIndices") + } + + /** + * Initiate Ctrl + Tab session if one is not already started. + */ + private fun tabSwitchStart() + { + if (iCapturedRecentTabsIndices==null) + { + copyRecentTabsList() + } + } + + /** + * Reset ctrl + tab session if one was started. + * Typically used when creating or deleting tabs. + */ + private fun tabSwitchReset() + { + if (iCapturedRecentTabsIndices!=null) + { + copyRecentTabsList() + } + } + + /** + * Stop ctrl + tab session. + * Typically when the ctrl key is released. + */ + private fun tabSwitchStop() + { + iCapturedRecentTabsIndices?.let { + // Replace our recent tabs list by putting our captured one back in place making sure the selected tab is going back on top + // See: https://github.com/Slion/Magic/issues/56 + tabsManager.iRecentTabs = it.toMutableSet() + val tab = tabsManager.iRecentTabs.elementAt(iRecentTabIndex) + tabsManager.iRecentTabs.remove(tab) + tabsManager.iRecentTabs.add(tab) + } + + iRecentTabIndex = -1; + iCapturedRecentTabsIndices = null; + //Timber.d("CTRL+TAB: Reset") + } + + /** + * Apply pending tab switch + */ + private fun tabSwitchApply(aGoingBack: Boolean) { + iCapturedRecentTabsIndices?.let { + if (iRecentTabIndex >= 0) { + // We worked out which tab to switch to, just do it now + tabsManager.tabChanged(tabsManager.indexOfTab(it.elementAt(iRecentTabIndex)), false, aGoingBack) + //mainHandler.postDelayed({tabsManager.tabChanged(tabsManager.indexOfTab(it.elementAt(iRecentTabIndex)))}, 300) + } + } + } + + /** + * Switch back to previous tab + */ + private fun tabSwitchBack() { + iCapturedRecentTabsIndices?.let { + iRecentTabIndex-- + if (iRecentTabIndex<0) iRecentTabIndex=it.size-1 + } + } + + /** + * Switch forward to previous tab + */ + private fun tabSwitchForward() { + iCapturedRecentTabsIndices?.let { + iRecentTabIndex++ + if (iRecentTabIndex >= it.size) iRecentTabIndex = 0 + } + } + + // Needed to workaround that WSA bug: + // https://github.com/Slion/Magic/issues/484 + var iCtrlLeftDown: Boolean = false + var iCtrlRightDown: Boolean = false + + /** + * Manage our key events. + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + + //Timber.d("dispatchKeyEvent $event") + + if (event.action == KeyEvent.ACTION_UP && (event.keyCode==KeyEvent.KEYCODE_CTRL_LEFT||event.keyCode==KeyEvent.KEYCODE_CTRL_RIGHT)) { + + // Keep track of CTRL states + if (event.keyCode == KeyEvent.KEYCODE_CTRL_LEFT) iCtrlLeftDown = false + if (event.keyCode == KeyEvent.KEYCODE_CTRL_RIGHT) iCtrlRightDown = false + + // Exiting CTRL+TAB mode + tabSwitchStop() + } + + // Keyboard shortcuts + if (event.action == KeyEvent.ACTION_DOWN) { + + // Used this to debug control usage on emulator as both ctrl and alt just don't work on emulator + //val isCtrlOnly = if (Build.PRODUCT.contains("sdk")) { true } else KeyEvent.metaStateHasModifiers(event.metaState, KeyEvent.META_CTRL_ON) + val isCtrlOnly = KeyEvent.metaStateHasModifiers(event.metaState, KeyEvent.META_CTRL_ON) + val isShiftOnly = KeyEvent.metaStateHasModifiers(event.metaState, KeyEvent.META_SHIFT_ON) + val isCtrlShiftOnly = KeyEvent.metaStateHasModifiers(event.metaState, KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) + // TODO: Should we enforce that? I guess it should not break F(x)tec Pro¹ when using proper keyboard driver. + val noMods = KeyEvent.metaStateHasModifiers(event.metaState, 0) + + when (event.keyCode) { + + // Find next or previous in page + KeyEvent.KEYCODE_F3 -> { + if (isShiftOnly) { + tabsManager.currentTab?.findPrevious() + } else if (isCtrlOnly) { + // Ctrl + F3 means use current selection to perform a search + // We fetch current selection from WebView using JavaScript + // TODO: Move JavaScript to WebViewEx + Timber.w("evaluateJavascript: text selection extraction") + currentTabView?.evaluateJavascript("(function(){return window.getSelection().toString()})()", + ValueCallback { + aSelection -> + // Our selection is within double quotes + if (aSelection.length >= 3) { + // Remove our quotes + val hint = aSelection.subSequence(1,aSelection.length-1).toString() + // Set our search query + tabsManager.currentTab?.searchQuery = hint + // Trigger our search + findInPage() + } + }) + } else { + tabsManager.currentTab?.findNext() + } + return true + } + // Toggle status bar visibility + KeyEvent.KEYCODE_F10 -> { + setFullscreen(!statusBarHidden, false) + return true + } + // Toggle tool bar visibility + KeyEvent.KEYCODE_F11 -> { + toggleToolBar() + return true + } + // Reload current tab + KeyEvent.KEYCODE_F5 -> { + // Refresh current tab + tabsManager.currentTab?.reload() + return true + } + + // Shortcut to focus text field + KeyEvent.KEYCODE_F6 -> { + actionFocusTextField() + return true + } + + // Move forward if WebView has focus + KeyEvent.KEYCODE_FORWARD -> { + if (tabsManager.currentTab?.webView?.hasFocus() == true && tabsManager.currentTab?.canGoForward() == true) { + currentTabGoForward() + return true + } + } + + // Keep track of CTRL states + KeyEvent.KEYCODE_CTRL_LEFT -> iCtrlLeftDown = true + KeyEvent.KEYCODE_CTRL_RIGHT -> iCtrlRightDown = true + + + // This is actually being done in onBackPressed and doBackAction + //KeyEvent.KEYCODE_BACK -> { + // if (tabsManager.currentTab?.webView?.hasFocus() == true && tabsManager.currentTab?.canGoBack() == true) { + // tabsManager.currentTab?.goBack() + // return true + // } + //} + } + + if (isCtrlOnly) { + // Ctrl + tab number for direct tab access + tabsManager.let { + if (KeyEvent.KEYCODE_0 <= event.keyCode && event.keyCode <= KeyEvent.KEYCODE_9) { + val nextIndex = if (event.keyCode > it.last() + KeyEvent.KEYCODE_1 || event.keyCode == KeyEvent.KEYCODE_0) { + // Go to the last tab for 0 or if not enough tabs + it.last() + } else { + // Otherwise access any of the first nine tabs + event.keyCode - KeyEvent.KEYCODE_1 + } + tabsManager.tabChanged(nextIndex,false, false) + return true + } + } + } + + if (isCtrlShiftOnly) { + // Ctrl + Shift + session number for direct session access + tabsManager.let { + if (KeyEvent.KEYCODE_0 <= event.keyCode && event.keyCode <= KeyEvent.KEYCODE_9) { + val nextIndex = if (event.keyCode > it.iSessions.count() + KeyEvent.KEYCODE_1 || event.keyCode == KeyEvent.KEYCODE_0) { + // Go to the last session if not enough sessions or KEYCODE_0 + it.iSessions.count()-1 + } else { + // Otherwise access any of the first nine sessions + event.keyCode - KeyEvent.KEYCODE_1 + } + tabsManager.switchToSession(it.iSessions[nextIndex].name) + return true + } + } + } + + // CTRL+TAB for tab cycling logic + if ((event.isCtrlPressed || iCtrlLeftDown || iCtrlRightDown) && event.keyCode == KeyEvent.KEYCODE_TAB) { + + // Entering CTRL+TAB mode + tabSwitchStart() + + iCapturedRecentTabsIndices?.let{ + + // Reversing can be done with those three modifiers notably to make it easier with two thumbs on F(x)tec Pro1 + if (event.isShiftPressed or event.isAltPressed or event.isFunctionPressed) { + // Go forward one tab + tabSwitchForward() + tabSwitchApply(false) + } else { + // Go back one tab + tabSwitchBack() + tabSwitchApply(true) + } + + //Timber.d("Switching to $iRecentTabIndex : $iCapturedRecentTabsIndices") + + } + + //Timber.d("Tab: down discarded") + return true + } + + when { + isCtrlOnly -> when (event.keyCode) { + KeyEvent.KEYCODE_F -> { + // Search in page + findInPage() + return true + } + KeyEvent.KEYCODE_T -> { + // Open new tab + if (isIncognito()) { + tabsManager.newTab( + incognitoPageInitializer, + true + ) + } else{ + tabsManager.newTab( + homePageInitializer, + true + ) + } + tabSwitchReset() + return true + } + KeyEvent.KEYCODE_D -> { + // Duplicate tab + tabsManager.currentTab?.let { + tabsManager.newTab(FreezableBundleInitializer(TabModelFromBundle(it.saveState())),true) + tabSwitchReset() + } + return true + } + KeyEvent.KEYCODE_F4, + KeyEvent.KEYCODE_W -> { + // Close current tab + tabsManager.let { tabsManager.deleteTab(it.indexOfCurrentTab()) } + tabSwitchReset() + return true + } + KeyEvent.KEYCODE_Q -> { + // Close browser + closeBrowser() + return true + } + // Mostly there because on F(x)tec Pro1 F5 switches off keyboard backlight + KeyEvent.KEYCODE_R -> { + // Refresh current tab + tabsManager.currentTab?.reload() + return true + } + // Show tab drawer displaying all pages + KeyEvent.KEYCODE_P -> { + toggleTabs() + return true + } + // Meritless shortcut matching Chrome's default + KeyEvent.KEYCODE_L -> { + actionFocusTextField() + return true + } + KeyEvent.KEYCODE_B -> { + executeAction(R.id.action_add_bookmark) + return true + } + // Text zoom in and out + // TODO: persist that setting per tab? + KeyEvent.KEYCODE_MINUS -> tabsManager.currentTab?.webView?.apply { + settings.textZoom = Math.max(settings.textZoom - 5, MIN_BROWSER_TEXT_SIZE) + application.toast(getText(R.string.size).toString() + ": " + settings.textZoom + "%") + return true + } + KeyEvent.KEYCODE_EQUALS -> tabsManager.currentTab?.webView?.apply { + settings.textZoom = Math.min(settings.textZoom + 5, MAX_BROWSER_TEXT_SIZE) + application.toast(getText(R.string.size).toString() + ": " + settings.textZoom + "%") + return true + } + } + + isCtrlShiftOnly -> when (event.keyCode) { + KeyEvent.KEYCODE_T -> { + toggleTabs() + return true + } + KeyEvent.KEYCODE_S -> { + toggleSessions() + return true + } + KeyEvent.KEYCODE_B -> { + toggleBookmarks() + return true + } + // Text zoom in and out + // TODO: persist that setting per tab? + KeyEvent.KEYCODE_MINUS -> tabsManager.currentTab?.webView?.apply { + settings.textZoom = Math.max(settings.textZoom - 1, MIN_BROWSER_TEXT_SIZE) + application.toast(getText(R.string.size).toString() + ": " + settings.textZoom + "%") + } + KeyEvent.KEYCODE_EQUALS -> tabsManager.currentTab?.webView?.apply { + settings.textZoom = Math.min(settings.textZoom + 1, MAX_BROWSER_TEXT_SIZE) + application.toast(getText(R.string.size).toString() + ": " + settings.textZoom + "%") + } + } + + event.keyCode == KeyEvent.KEYCODE_SEARCH -> { + // Highlight search field + searchView.requestFocus() + return true + } + } + } + + return super.dispatchKeyEvent(event) + } + + /** + * Used to skip the undo tab close option for empty tabs we closed automatically + */ + private var skipNextTabClosedSnackbar : Boolean = false + + /** + * Used to close empty tab after opening download link or launching app. + */ + fun closeCurrentTabIfEmpty() { + // Had to delay that otherwise we could get there too early on the url still contains the download link + // URL is later on reset to null by WebView internal mechanics. + mainHandler.postDelayed({ + if (currentTabView?.url.isNullOrBlank()) { + skipNextTabClosedSnackbar = true + tabsManager.deleteTab(tabsManager.indexOfCurrentTab()) + } + }, 500); + } + + + /** + * + */ + override fun executeAction(@IdRes id: Int): Boolean { + + val currentView = tabsManager.currentTab + val currentUrl = currentView?.url + + when (id) { + android.R.id.home -> { + if (showingBookmarks()) { + closePanelBookmarks() + } + return true + } + R.id.action_back -> { + if (currentView?.canGoBack() == true) { + currentTabGoBack() + } + return true + } + R.id.action_forward -> { + if (currentView?.canGoForward() == true) { + currentTabGoForward() + } + return true + } + R.id.action_add_to_homescreen -> { + if (currentView != null + && currentView.url.isNotBlank() + && !currentView.url.isSpecialUrl()) { + HistoryEntry(currentView.url, currentView.title).also { + Utils.createShortcut(this, it, currentView.favicon) + Timber.d("Creating shortcut: ${it.title} ${it.url}") + } + } + return true + } + R.id.action_new_tab -> { + if (isIncognito()) { + tabsManager.newTab( + incognitoPageInitializer, + true + ) + } else { + tabsManager.newTab( + homePageInitializer, + true + ) + } + return true + } + R.id.action_reload -> { + if (searchView.hasFocus()) { + // SL: Not sure why? + searchView.setText("") + } else { + refreshOrStop() + } + return true + } + R.id.action_incognito -> { + startActivity(IncognitoActivity.createIntent(this)) + overridePendingTransition(R.anim.slide_up_in, R.anim.fade_out_scale) + return true + } + R.id.action_share -> { + shareUrl(currentUrl, currentView?.title) + return true + } + R.id.action_bookmarks -> { + openBookmarks() + return true + } + R.id.action_exit -> { + closeBrowser() + return true + } + R.id.action_copy -> { + if (currentUrl != null && !currentUrl.isSpecialUrl()) { + clipboardManager.copyToClipboard(currentUrl) + snackbar(R.string.message_link_copied) + } + return true + } + R.id.action_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + // Was there just for testing it + //onMaxTabReached() + return true + } + R.id.action_history -> { + openHistory() + return true + } + R.id.action_downloads -> { + openDownloads() + return true + } + R.id.action_add_bookmark -> { + if (currentUrl != null && !currentUrl.isSpecialUrl()) { + addBookmark(currentView.title, currentUrl) + } + return true + } + R.id.action_find -> { + findInPage() + return true + } + + R.id.action_translate -> { + // Get our local + val locale = LocaleUtils.requestedLocale(userPreferences.locale) + // For most languages Google just wants the two letters code + // Using the full language tag such as fr-FR will actually prevent Google translate… + // …to display the target language name even though the translation is actually working + var languageCode = locale.language + val languageTag = locale.toLanguageTag() + // For chinese however, Google translate expects the full language tag + if (languageCode == "zh") { + languageCode = languageTag + } + + // TODO: Have a settings option to translate in new tab + tabsManager.loadUrlInCurrentView("https://translate.google.com/translate?sl=auto&tl=$languageCode&u=$currentUrl") + // TODO: support other translation providers? + //tabsManager.loadUrlInCurrentView("https://www.translatetheweb.com/?from=&to=$locale&dl=$locale&a=$currentUrl") + return true + } + + R.id.action_print -> { + currentTabView?.print() + return true + } + R.id.action_reading_mode -> { + if (currentUrl != null) { + ReadingActivity.launch(this, currentUrl, false) + } + return true + } + R.id.action_restore_page -> { + tabsManager.recoverClosedTab() + return true + } + R.id.action_restore_all_pages -> { + tabsManager.recoverAllClosedTabs() + return true + } + + R.id.action_close_all_tabs -> { + // TODO: consider just closing all tabs + // TODO: Confirmation dialog + //closeBrowser() + //tabsManager.closeAllTabs() + tabsManager.closeAllOtherTabs() + return true + } + + R.id.action_show_homepage -> { + if (userPreferences.homepageInNewTab) { + if (isIncognito()) { + tabsManager.newTab(incognitoPageInitializer, true) + } else { + tabsManager.newTab(homePageInitializer, true) + } + } else { + // Why not through presenter We need some serious refactoring at some point + tabsManager.currentTab?.loadHomePage() + } + closePanels() + return true + } + + R.id.action_toggle_desktop_mode -> { + tabsManager.currentTab?.apply { + toggleDesktopUserAgent() + reload() + } + return true + } + + R.id.action_toggle_dark_mode -> { + tabsManager.currentTab?.apply { + toggleDarkMode() + // Calling setToolbarColor directly from here causes that old bug with WebView not resizing when hiding toolbar and not showing newly loaded WebView to resurface. + // Even doing a post does not fix it. However doing a long enough postDelayed does the trick. + mainHandler.postDelayed({ setToolbarColor() }, 100) + } + return true + } + + R.id.action_block -> { + + abpUserRules.allowPage(Uri.parse(tabsManager.currentTab?.url), !iMenuWebPage.iBinding.menuItemAdBlock.isChecked) + tabsManager.currentTab?.reload() + return true + } + + R.id.action_sessions -> { + // Show sessions menu + showSessions() + return true + } + + else -> return false + } + } + + // Legacy from menu framework. Since we are using custom popup window as menu we don't need this anymore. + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (executeAction(item.itemId)) true else super.onOptionsItemSelected(item) + } + + + // By using a manager, adds a bookmark and notifies third parties about that + private fun addBookmark(title: String, url: String) { + val bookmark = Bookmark.Entry(url, title, 0, Bookmark.Folder.Root) + bookmarksDialogBuilder.showAddBookmarkDialog(this, this, bookmark) + } + + private fun deleteBookmark(title: String, url: String) { + bookmarkManager.deleteBookmark(Bookmark.Entry(url, title, 0, Bookmark.Folder.Root)) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { boolean -> + if (boolean) { + handleBookmarksChange() + } + } + } + + + /** + * Do find in page actions + */ + private fun doFindInPage() { + iBinding.findInPageInclude.searchQuery.let { + it.requestFocus() + // Crazy workaround to get the virtual keyboard to show, Android FFS + // See: https://stackoverflow.com/a/7784904/3969362 + mainHandler.postDelayed({ + // Emulate tap to open up soft keyboard if needed + it.simulateTap() + // That will trigger our search, see addTextChangedListener + it.setText(tabsManager.currentTab?.searchQuery) + // Move cursor to the end of our text + it.setSelection(it.length()) + }, 100) + } + } + + /** + * Two code path depending on whether our search view is already visible. + * See: https://github.com/Slion/Magic/issues/402 + */ + private fun findInPage() { + iBinding.findInPageInclude.apply { + if (root.isVisible) { + // View is already visible + doFindInPage() + } else { + // Wait for view to be visible + searchQuery.doOnLayout { + doFindInPage(); + } + root.isVisible = true + } + } + } + + + private fun isLoading() : Boolean = tabsManager.currentTab?.let{it.progress < 100} ?: false + + /** + * Enable or disable pull-to-refresh according to user preferences and state + */ + private fun setupPullToRefresh(configuration: Configuration) { + if (!configPrefs.pullToRefresh) { + // User does not want to use pull to refresh + iTabViewContainerFront.isEnabled = false + iBindingToolbarContent.buttonReload.visibility = View.VISIBLE + return + } + + // Disable pull to refresh if no vertical scroll as it bugs with frame internal scroll + // See: https://github.com/Slion/Lightning-Browser/projects/1 + iTabViewContainerFront.isEnabled = currentTabView?.canScrollVertically()?:false + + updateReloadButton() + } + + /** + * + */ + private fun updateReloadButton() { + // Don't show reload button if pull-to-refresh is enabled and once we are not loading + iBindingToolbarContent.buttonReload.isVisible = !iTabViewContainerFront.isEnabled || isLoading() + iBindingToolbarContent.buttonReload.setImageResource(if (isLoading()) R.drawable.ic_action_delete else R.drawable.ic_action_refresh); + } + + /** + * Reset our tab bar if needed. + * Notably used after configuration change. + */ + private fun setupTabBar(configuration: Configuration): Boolean { + // Check if our tab bar style changed + if (verticalTabBar!=configPrefs.verticalTabBar + || tabBarInDrawer!=configPrefs.tabBarInDrawer + // Our bottom sheets dialog needs to be recreated with proper window decor state, with or without status bar that is. + // Looks like that was also needed for the bottom sheets top padding to be in sync? Go figure… + || userPreferences.useBottomSheets) { + // We either coming or going to desktop like horizontal tab bar, tabs panel should be closed then + mainHandler.post {closePanelTabs()} + // Tab bar style changed recreate our tab bar then + createTabsView() + tabsView?.tabsInitialized() + mainHandler.postDelayed({ tryScrollToCurrentTab() }, 1000) + return true + } + + return false + } + + /** + * Tells if web page color should be applied to tool and status bar + */ + override fun isColorMode(): Boolean = userPreferences.colorModeEnabled + + override fun getTabModel(): TabsManager = tabsManager + + // TODO: That's not being used anymore + override fun showCloseDialog(position: Int) { + if (position < 0) { + return + } + BrowserDialog.show(this, R.string.dialog_title_close_browser, + DialogItem(title = R.string.close_tab) { + tabsManager.deleteTab(position) + }, + DialogItem(title = R.string.close_other_tabs) { + tabsManager.closeAllOtherTabs() + }, + DialogItem(title = R.string.close_all_tabs, onClick = this::closeBrowser) + ) + } + + /** + * From [WebBrowser]. + */ + override fun notifyTabViewRemoved(position: Int) { + Timber.d("Notify Tab Removed: $position") + tabsView?.tabRemoved(position) + + if (userPreferences.onTabCloseShowSnackbar && !skipNextTabClosedSnackbar) { + // Notify user a tab was closed with an option to recover it + makeSnackbar( + getString(R.string.notify_tab_closed), Snackbar.LENGTH_SHORT, if (configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM) + .setAction(R.string.button_undo) { + tabsManager.recoverClosedTab() + }.show() + } + skipNextTabClosedSnackbar = false + } + + /** + * From [WebBrowser]. + */ + override fun notifyTabViewAdded() { + Timber.d("Notify Tab Added") + tabsView?.tabAdded() + } + + /** + * From [WebBrowser]. + * + */ + override fun notifyTabViewChanged(position: Int) { + Timber.d("Notify Tab Changed: $position") + tabsView?.tabChanged(position) + setToolbarColor() + setupPullToRefresh(resources.configuration) + } + + /** + * From [WebBrowser]. + */ + override fun notifyTabViewInitialized() { + Timber.d("Notify Tabs Initialized") + tabsView?.tabsInitialized() + } + + /** + * TODO: Defined both in [WebBrowser] and [WebBrowser] + * Sort out that mess. + */ + override fun updateSslState(sslState: SslState) { + iBindingToolbarContent.addressBarInclude.searchSslStatus.setImageDrawable(createSslDrawableForState(sslState)) + + if (!searchView.hasFocus()) { + iBindingToolbarContent.addressBarInclude.searchSslStatus.updateVisibilityForContent() + } + } + + private fun ImageView.updateVisibilityForContent() { + drawable?.let { visibility = VISIBLE } ?: run { visibility = GONE } + } + + /** + * + */ + override fun onPageStarted(aTab: WebPageTab) { + if (tabsManager.currentTab==aTab) { + setTaskDescription() + } + + // SL: Is this being called way too many times? + doTabUpdate(aTab) + // SL: Putting this here to update toolbar background color was a bad idea + // That somehow freezes the WebView after switching between a few tabs on F(x)tec Pro1 at least (Android 9) + //initializePreferences() + + } + + /** + * + */ + override fun onTabChangedUrl(aTab: WebPageTab) { + Timber.d("onTabChangedUrl") + + if (tabsManager.currentTab==aTab) { + setTaskDescription() + updateUrl(aTab.url,isLoading()) + } + + } + + /** + * + */ + override fun onTabChanged(aTab: WebPageTab) { + if (tabsManager.currentTab==aTab) { + setTaskDescription() + } + + // SL: Is this being called way too many times? + doTabUpdate(aTab) + // SL: Putting this here to update toolbar background color was a bad idea + // That somehow freezes the WebView after switching between a few tabs on F(x)tec Pro1 at least (Android 9) + //initializePreferences() + } + + /** + * + */ + override fun onTabChangedIcon(aTab: WebPageTab) { + if (tabsManager.currentTab==aTab) { + setTaskDescription() + } + + // TODO: optimize for icon only update + doTabUpdate(aTab) + } + + /** + * + */ + override fun onTabChangedTitle(aTab: WebPageTab) { + if (tabsManager.currentTab==aTab) { + setTaskDescription() + } + + // TODO: optimize for title only update + doTabUpdate(aTab) + } + + + /** + * + */ + private fun doTabUpdate(aTab: WebPageTab) { + notifyTabViewChanged(tabsManager.indexOfTab(aTab)) + } + + + private var iTappedTab : WebPageTab? = null + + /** + * + */ + override fun onSingleTapUp(aTab: WebPageTab) { + if (aTab!=tabsManager.currentTab) { + return + } + + // TODO: Discard anchor links hit? Like the one from BBC menu drawer. + aTab.webView?.hitTestResult?.let { + Timber.i("onSingleTapUp: ${it.type}") + if (it.type==SRC_ANCHOR_TYPE || it.type==SRC_IMAGE_ANCHOR_TYPE) { + // Remember the tapped tab and we will start animation if that results in a page load from onProgressChanged with short delay + // GitHub page navigation notably needed more 600ms from tap progress notification + iTappedTab = aTab + mainHandler.postDelayed({ + iTappedTab = null + },1000) + // Animate our tab +// if (iTabAnimator==null && userPreferences.onPageStartedShowAnimation && aTab.isLoading) { +// animateTabFlipLeft(iTabViewContainerFront) +// } + } + } + } + + /** + * + */ + private fun setupToolBarButtons() { + // Manage back and forward buttons state + tabsManager.currentTab?.apply { + iBindingToolbarContent.buttonActionBack.apply { + isEnabled = canGoBack() + // Since we set buttons color ourselves we need this to show it is disabled + alpha = if (isEnabled) 1.0f else 0.25f + } + + iBindingToolbarContent.buttonActionForward.apply { + isEnabled = canGoForward() + // Since we set buttons color ourselves we need this to show it is disabled + alpha = if (isEnabled) 1.0f else 0.25f + } + } + } + + /** + * + */ + fun vibrate() { + val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + if (vibrator.hasVibrator()) { // Vibrator availability checking + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(EFFECT_DOUBLE_CLICK)) + // + } else*/ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(longArrayOf(50,50,50), intArrayOf(10,0,10), -1)) + //vibrator.vibrate(VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)) + } + else { + vibrator.vibrate(200) // Vibrate method for below API Level 26 + } + } + } + + /** + * + */ + private fun swapTabViewsFrontToBack() { + // Actually move our back frame below our front frame + iTabViewContainerBack.removeFromParent()?.addView(iTabViewContainerBack,0) + } + + + var iSkipNextSearchQueryUpdate = false + + /** + * From [WebBrowser]. + * This function is central to browser tab switching. + * It swaps our previous WebView with our new WebView. + * + * [aView] Input is in fact a [WebViewEx]. + */ + override fun setTabView(aView: View, aWasTabAdded: Boolean, aPreviousTabClosed: Boolean, aGoingBack: Boolean) { + Timber.i("setTabView") + if (lastTabView == aView) { + Timber.d("setTabView: tab already set") + return + } + + aView.removeFromParent() // Just to be safe + + // Skip tab animation if user does not want it… + val skipAnimation = !userPreferences.onTabChangeShowAnimation + // …or if we already have a tab animation running + || iTabAnimator!=null + + // If we have not swapped our views yet + if (skipAnimation) { + // Just perform our layout changes in our front view container then + // We need to specify the layout params otherwise WebView fails in the strangest way. + // In fact Web pages using popup and side menu will be broken as those elements background won't render, it'd look just transparent. + // Issue could be reproduced on firebase console side menu and some BBC consent pop-up + iTabViewContainerFront.addView(aView,0, MATCH_PARENT) + lastTabView?.removeFromParent() + iTabViewContainerFront.resetTarget() // Needed to make it work together with swipe to refresh + aView.requestFocus() + } else { + // Remove place holder + iPlaceHolder?.removeFromParent() + // Prepare our back view container then + iTabViewContainerBack.resetTarget() // Needed to make it work together with swipe to refresh + // Same as above, make sure you specify layout params when adding you web view + iTabViewContainerBack.addView(aView, MATCH_PARENT) + aView.requestFocus() + } + + // Remove existing focus change observer before we change our tab + lastTabView?.onFocusChangeListener = null + // Change our tab + lastTabView = aView + // Close virtual keyboard if we loose focus + currentTabView.onFocusLost { inputMethodManager.hideSoftInputFromWindow(iBinding.uiLayout.windowToken, 0) } + // Now everything is ready below our image snapshot of current view + // Last perform our transitions + if (!skipAnimation) { + // Swap our variables but not the views yet + val front = iTabViewContainerBack + iTabViewContainerBack = iTabViewContainerFront + iTabViewContainerFront = front + // + if (aWasTabAdded) { + animateTabInScaleUp(iTabViewContainerFront) + } else if (aPreviousTabClosed) { + animateTabOutScaleDown(iTabViewContainerBack) + if (userPreferences.onTabCloseVibrate) { + vibrate() + } + } + else { + //iBinding.imageBelow.isVisible = false // Won't be needed in this case + if (aGoingBack) { + animateTabOutRight(iTabViewContainerBack) + animateTabInRight(iTabViewContainerFront) + } else { + animateTabOutLeft(iTabViewContainerBack) + animateTabInLeft(iTabViewContainerFront) + } + } + } + showActionBar() + // Make sure current tab is visible in tab list + tryScrollToCurrentTab() + //mainHandler.postDelayed({ scrollToCurrentTab() }, 0) + + // Current tab was already set by the time we get here + tabsManager.currentTab?.let { + // Update our find in page UI as needed + iSkipNextSearchQueryUpdate = true // Make sure we don't redo a search as our UI text is changed + iBinding.findInPageInclude.searchQuery.setText(it.searchQuery) + // Set find in page UI visibility + iBinding.findInPageInclude.root.isVisible = it.searchActive + } + } + + private val iTabAnimationDuration: Long = 300 + + /** + * That's intended to show the user a new tab was created + */ + private fun animateTabInScaleUp(aTab: View?) { + assertNull(iTabAnimator) + aTab?.let{ + //iBinding.webViewFrame.addView(it, MATCH_PARENT) + // Set our properties + it.scaleX = 0f + it.scaleY = 0f + // Put our incoming frame on top + // This replaces swapTabViewsFrontToBack for this special case + // Still must not be on top of floating action buttons (FAB) touch tab switcher + it.removeFromParent()?.addView(it,1) + // Animate it + iTabAnimator = it.animate() + .scaleY(1f) + .scaleX(1f) + .setDuration(iTabAnimationDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + //Timber.d(Log.getStackTraceString(Exception())) + aTab.post { + it.scaleX = 1f + it.scaleY = 1f + iTabViewContainerBack.findViewById(R.id.web_view)?.apply{ + removeFromParent()?.addView(iPlaceHolder) + //destroyIfNeeded() + } + // + iTabAnimator = null; + } + } + }) + } + } + + /** + * Intended to show user a tab was closed. + */ + private fun animateTabOutScaleDown(aTab: View?) { + assertNull(iTabAnimator) + aTab?.let{ + iTabAnimator = it.animate() + .scaleY(0f) + .scaleX(0f) + .setDuration(iTabAnimationDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + //Timber.d(Log.getStackTraceString(Exception())) + + aTab.post { + // Time to swap our frames + swapTabViewsFrontToBack() + + // Now do the clean-up + iTabViewContainerBack.findViewById(R.id.web_view)?.apply{ + removeFromParent()?.addView(iPlaceHolder) + destroyIfNeeded() + } + + // Reset our properties + it.scaleX = 1.0f + it.scaleY = 1.0f + // + iTabAnimator = null; + } + } + }) + } + } + + + /** + * Intended to show user a tab was sent to the background. + * Animate a tab that's being sent to the background. + * Designed to work together with [animateTabInRight]. + */ + private fun animateTabOutRight(aTab: View?) { + assertNull(iTabAnimator) + aTab?.let{ + // Move our tab to a frame were we can animate it on top of our new foreground tab + iTabAnimator = it.animate() + // Move our tab outside of the screen to the right + .translationX(it.width.toFloat()) + .setDuration(iTabAnimationDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + //Timber.d(Log.getStackTraceString(Exception())) + + aTab.post { + // Put outgoing frame in the back + swapTabViewsFrontToBack() + // Animation is complete unhook that tab then + it.findViewById(R.id.web_view)?.apply { + removeFromParent()?.addView(iPlaceHolder) + //destroyIfNeeded() + } + + // Reset our properties + it.translationX = 0f + // + iTabAnimator = null + } + } + }) + } + } + + /** + * Intended to show user a tab was sent to the background. + * Animate a tab that's being sent to the background. + * Designed to work together with [animateTabInLeft]. + */ + private fun animateTabOutLeft(aTab: View?) { + assertNull(iTabAnimator) + aTab?.let{ + // Move our tab to a frame were we can animate it on top of our new foreground tab + iTabAnimator = it.animate() + // Move our tab outside of the screen to the left + .translationX(-it.width.toFloat()) + .setDuration(iTabAnimationDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + //Timber.d(Log.getStackTraceString(Exception())) + aTab.post{ + // Put outgoing frame in the back + swapTabViewsFrontToBack() + // Animation is complete unhook that tab then + it.findViewById(R.id.web_view)?.apply{ + removeFromParent()?.addView(iPlaceHolder) + //destroyIfNeeded() + } + + // Reset our properties + it.translationX = 0f + // + iTabAnimator = null; + } + } + }) + } + } + + /** + * Animate an incoming tab from the left to the right. + * Designed to work together with [animateTabOutRight]. + */ + private fun animateTabInRight(aTab: View?) { + aTab?.let{ + it.translationX = -it.width.toFloat() + // Move our tab to a frame were we can animate it on top of our new foreground tab + it.animate() + // Move our tab outside of the screen to the right + .translationX(0f) + .setDuration(iTabAnimationDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + // Animation is complete + // Reset our properties + it.translationX = 0f + } + }) + } + } + + /** + * Animate an incoming tab from the right to the left. + * Designed to work together with [animateTabOutLeft]. + */ + private fun animateTabInLeft(aTab: View?) { + aTab?.let{ + // Initial tab position in offset to the right outside the screen + it.translationX = it.width.toFloat() + it.animate() + // Move our tab to its default layout position on the screen + .translationX(0f) + .setDuration(iTabAnimationDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + // Animation is complete + // Reset our properties + it.translationX = 0f + } + }) + } + } + + + /** + * Used when going forward in tab history + */ + private fun animateTabFlipLeft(aTab: View?) { + assertNull(iTabAnimator) + aTab?.let{ + // Adjust camera distance to avoid clipping + val scale = resources.displayMetrics.density + it.cameraDistance = it.width * scale * 2 + iTabAnimator = it.animate() + .rotationY(360f) + .setDuration(userPreferences.onTabBackAnimationDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + it.rotationY = 0f + // + iTabAnimator = null; + } + }) + } + } + + /** + * Used when going back in tab history + */ + private fun animateTabFlipRight(aTab: View?) { + assertNull(iTabAnimator) + + aTab?.let{ + // Adjust camera distance to avoid clipping + val scale = resources.displayMetrics.density + it.cameraDistance = it.width * scale * 2 + + iTabAnimator = it.animate() + .rotationY(-360f) + .setDuration(userPreferences.onTabBackAnimationDuration.toLong()) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + it.rotationY = 0f + // + iTabAnimator = null; + } + }) + } + } + + + /** + * Used when going forward in page history + */ + fun animateTabFlipLeft() { + if (iTabAnimator==null && userPreferences.onTabBackShowAnimation) { + animateTabFlipLeft(iTabViewContainerFront) + } + } + + /** + * Used when going backward in page history + */ + fun animateTabFlipRight() { + if (iTabAnimator==null && userPreferences.onTabBackShowAnimation) { + animateTabFlipRight(iTabViewContainerFront) + } + } + + override fun showBlockedLocalFileDialog(onPositiveClick: Function0) { + MaterialAlertDialogBuilder(this) + .setCancelable(true) + .setTitle(R.string.title_warning) + .setMessage(R.string.message_blocked_local) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.action_open) { _, _ -> onPositiveClick.invoke() } + .resizeAndShow() + } + + override fun showSnackbar(@StringRes resource: Int) = snackbar(resource, if (configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM) + + fun showSnackbar(aMessage: String) = snackbar(aMessage, if (configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM) + + override fun tabCloseClicked(position: Int) { + tabsManager.deleteTab(position) + } + + override fun tabClicked(position: Int) { + // Switch tab + tabsManager.tabChanged(position,false, false) + // Keep the drawer open while the tab change animation in running + // Has the added advantage that closing of the drawer itself should be smoother as the webview had a bit of time to load + mainHandler.postDelayed({ closePanels() }, 350) + } + + // This is the callback from 'new tab' button on page drawer + override fun newTabButtonClicked() { + // First close drawer + closePanels() + // Then slightly delay page loading to give enough time for the drawer to close without stutter + mainHandler.postDelayed({ + if (isIncognito()) { + tabsManager.newTab( + incognitoPageInitializer, + true + ) + } else { + tabsManager.newTab( + homePageInitializer, + true + ) + } + }, 300) + } + + override fun newTabButtonLongClicked() { + tabsManager.recoverClosedTab() + } + + override fun bookmarkButtonClicked() { + val currentTab = tabsManager.currentTab + val url = currentTab?.url + val title = currentTab?.title + if (url == null || title == null) { + return + } + + if (!url.isSpecialUrl()) { + bookmarkManager.isBookmark(url) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { boolean -> + if (boolean) { + deleteBookmark(title, url) + } else { + addBookmark(title, url) + } + } + } + } + + override fun bookmarkItemClicked(entry: Bookmark.Entry) { + if (userPreferences.bookmarkInNewTab) { + tabsManager.newTab(UrlInitializer(entry.url), true) + } else { + tabsManager.loadUrlInCurrentView(entry.url) + } + // keep any jank from happening when the drawer is closed after the URL starts to load + mainHandler.postDelayed({ closePanels() }, 150) + } + + /** + * Is that supposed to reload our history page if it changes? + * Are we rebuilding our history page every time our history is changing? + * Meaning every time we load a web page? + * Thankfully not, apparently. + */ + override fun handleHistoryChange() { + historyPageFactory + .buildPage() + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribeBy(onSuccess = { tabsManager.currentTab?.reload() }) + } + + protected fun handleNewIntent(intent: Intent) { + tabsManager.onNewIntent(intent) + } + + protected fun performExitCleanUp() { + exitCleanup.cleanUp(tabsManager.currentTab?.webView, this) + } + + /** + * Called notably when the device orientation was changed. + * + * See: [Activity.onConfigurationChanged] + */ + override fun onConfigurationChanged(aNewConfig: Configuration) { + Timber.d("onConfigurationChanged - $configId") + updateConfigurationSharedPreferences() + + super.onConfigurationChanged(aNewConfig) + + updateConfiguration() + + iMenuMain.dismiss() // As it wont update somehow + iMenuWebPage.dismiss() + // Make sure our drawers adjust accordingly + iBinding.drawerLayout.requestLayout() + + } + + /** + * + */ + private fun initializeToolbarHeight(configuration: Configuration) = + iBinding.uiLayout.doOnLayout { + // TODO externalize the dimensions + val toolbarSize = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + R.dimen.toolbar_height_portrait + } else { + R.dimen.toolbar_height_landscape + } + + iBinding.toolbarInclude.toolbar.layoutParams.height = dimen(toolbarSize) + iBinding.toolbarInclude.toolbar.minimumHeight = toolbarSize + iBinding.toolbarInclude.toolbar.requestLayout() + } + + /** + * + */ + override fun closeBrowser() { + currentTabView?.removeFromParent() + + performExitCleanUp() + finishAndRemoveTask() + } + + override fun onPause() { + super.onPause() + Timber.d("onPause") + + tabsManager.pauseAll() + + // Dismiss any popup menu + iMenuMain.dismiss() + iMenuWebPage.dismiss() + iMenuSessions.dismiss() + + if (isIncognito() && isFinishing) { + overridePendingTransition(R.anim.fade_in_scale, R.anim.slide_down_out) + } + } + + override fun onBackPressed() { + doBackAction() + } + + /** + * Go back in current tab history. + * Perform animation as specified in settings. + */ + private fun currentTabGoBack() { + tabsManager.currentTab?.let { + it.goBack() + // If no animation running yet… + if (iTabAnimator==null && + //…and user wants animation + userPreferences.onTabBackShowAnimation) { + animateTabFlipRight(iTabViewContainerFront) + } + } + } + + /** + * Go forward in current tab history. + * Perform animation as specified in settings. + */ + private fun currentTabGoForward() { + tabsManager.currentTab?.let { + it.goForward() + // If no animation running yet… + if (iTabAnimator==null + //…and user wants animation + && userPreferences.onTabBackShowAnimation) { + animateTabFlipLeft(iTabViewContainerFront) + } + } + } + + /** + * + */ + private fun doBackAction() { + val currentTab = tabsManager.currentTab + if (iJustClosedMenu) { + return + } + + if (showingTabs()) { + closePanelTabs() + } else if (showingBookmarks()) { + bookmarksView?.navigateBack() + } else { + if (currentTab != null) { + Timber.d("onBackPressed") + if (searchView.hasFocus()) { + currentTab.requestFocus() + } else if (currentTab.canGoBack()) { + if (!currentTab.isShown) { + onHideCustomView() + } else { + if (isToolBarVisible()) { + currentTabGoBack() + } else { + showActionBar() + } + } + } else { + if (customView != null || customViewCallback != null) { + onHideCustomView() + } else { + if (isToolBarVisible()) { + tabsManager.deleteTab(tabsManager.positionOf(currentTab)) + } else { + showActionBar() + } + } + } + } else { + Timber.d("This shouldn't happen ever") + super.onBackPressed() + } + } + } + + override fun onStop() { + super.onStop() + proxyUtils.onStop() + } + + /** + * Amazingly this is not called when closing our app from Task list. + * See: https://developer.android.com/reference/android/app/Activity.html#onDestroy() + * + * NOTE: Moreover when restarting this activity this is called after the onCreate of the new activity. + */ + override fun onDestroy() { + Timber.d("onDestroy") + + // Break cycling references that would trip GC + // Must be needed since View holds a reference to this as a context + // Though I'm pretty sure this activity is locked in some other ways, probably a lost cause at this stage... + iPlaceHolder = null + // + queue.cancelAll(TAG) + + incognitoNotification?.hide() + + mainHandler.removeCallbacksAndMessages(null) + + // Presenter and tab manager, which are tightly coupled, are owned by the application now. + // Therefore we should not clean and destroy them here as they will survive that activity. + // Instead we just unhook our tab. + // Actually even that is a bad idea since it could already be in used by another instance of that activity. + //removeTabView() + // That would do, not strictly needed though + lastTabView = null + + // + super.onDestroy() + } + + override fun onStart() { + super.onStart() + proxyUtils.onStart(this) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + Timber.d("onRestoreInstanceState") + super.onRestoreInstanceState(savedInstanceState) + tabsManager.shutdown() + } + + /** + * + */ + override fun onResume() { + updateConfigurationSharedPreferences() + super.onResume() + Timber.d("onResume") + // Check if some settings changes require application restart + if (swapBookmarksAndTabs != userPreferences.bookmarksAndTabsSwapped + || analytics != userPreferences.analytics + || crashReport != userPreferences.crashReport + || showCloseTabButton != userPreferences.showCloseTabButton + ) { + restart() + } + + // Lock our drawers when not in use, I wonder if this logic should be applied elsewhere + // TODO: Tab drawer should be locked when not in use, but not the bookmarks drawer + // TODO: Consider !configPrefs.verticalTabBar and !configPrefs.tabBarInDrawer + if (userPreferences.lockedDrawers + // We need to lock our drawers when using bottom sheets + // See: https://github.com/Slion/Magic/issues/192 + || userPreferences.useBottomSheets + ) { + lockDrawers() + } + else { + unlockDrawers() + } + + if (userPreferences.bookmarksChanged) + { + handleBookmarksChange() + userPreferences.bookmarksChanged = false + } + + if (userPreferences.incognito) { + WebUtils.clearHistory(this, historyModel, databaseScheduler) + WebUtils.clearCookies() + } + + suggestionsAdapter?.let { + it.refreshPreferences() + it.refreshBookmarks() + } + + tabsManager.resumeAll() + initializePreferences() + + updateConfiguration() + + // We think that's needed in case there was a rotation while in the background + iBinding.drawerLayout.requestLayout() + + //currentTabView?.removeFromParent()?.addView(currentTabView) + + //intent?.let {Timber.d(it.toString())} + } + + /** + * We used that to solve issues with drawers sometimes breaking layout when empty after rotations. + * Notably an issue when using bottom sheet. + */ + private fun setupDrawers() { + if (userPreferences.useBottomSheets) { + // We don't need drawers when using bottom sheets + iBinding.leftDrawer.removeFromParent() + iBinding.rightDrawer.removeFromParent() + } else { + // We may need drawers then, though it could be that the tab drawer is still not used + // Notably when using embedded tab bar, vertical or horizontal + if (iBinding.leftDrawer.parent==null) { + iBinding.drawerLayout.addView(iBinding.leftDrawer) + } + if (iBinding.rightDrawer.parent==null) { + iBinding.drawerLayout.addView(iBinding.rightDrawer) + } + } + } + + /** + * We need to make sure bookmarks are shown at the right place + * Potentially moving them from the bottom sheets back to the drawers + */ + private fun setupBookmarksView() { + if (userPreferences.useBottomSheets) { + createBookmarksDialog() + } else { + bookmarksView?.removeFromParent() + getBookmarksContainer().addView(bookmarksView) + } + + } + + + /** + * searches the web for the query fixing any and all problems with the input + * checks if it is a search, url, etc. + */ + private fun searchTheWeb(query: String) { + val currentTab = tabsManager.currentTab + if (query.isEmpty()) { + return + } + val searchUrl = "$searchText$QUERY_PLACE_HOLDER" + + val (url, isSearch) = smartUrlFilter(query.trim(), true, searchUrl) + + if ((userPreferences.searchInNewTab && isSearch) or (userPreferences.urlInNewTab && !isSearch)) { + // Create a new tab according to user preference + // TODO: URI resolution should not be here really + // That's also done in [WebPageTab].loadURL + when { + url.isHomeUri() -> { + tabsManager.newTab(homePageInitializer, true) + } + url.isIncognitoUri() -> { + tabsManager.newTab(incognitoPageInitializer, true) + } + url.isBookmarkUri() -> { + tabsManager.newTab(bookmarkPageInitializer, true) + } + url.isHistoryUri() -> { + tabsManager.newTab(historyPageInitializer, true) + } + else -> { + tabsManager.newTab(UrlInitializer(url), true) + } + } + } + else if (currentTab != null) { + // User don't want us the create a new tab + currentTab.stopLoading() + tabsManager.loadUrlInCurrentView(url) + } + } + + /** + * + */ + private fun setStatusBarColor(color: Int, darkIcons: Boolean) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // You don't want this as it somehow prevents smooth transition of tool bar when opening drawer + //window.statusBarColor = R.color.transparent + } + backgroundDrawable.color = color + window.setBackgroundDrawable(backgroundDrawable) + // That if statement is preventing us to change the icons color while a drawer is showing + // That's typically the case when user open a drawer before the HTML meta theme color was delivered + + //if (!tabsDialog.isShowing && !bookmarksDialog.isShowing) + if (drawerClosing || !drawerOpened) // Do not update icons color if drawer is opened + { + // Make sure the status bar icons are still readable + window.setStatusBarIconsColor(darkIcons && !userPreferences.useBlackStatusBar) + } + } + + /** + * Apply given color and derivative to our toolbar, its components and status bar too. + */ + private fun applyToolbarColor(color: Int) { + //Workout a foreground color that will be working with our background color + currentToolBarTextColor = foregroundColorFromBackgroundColor(color) + // Change search view text color + searchView.setTextColor(currentToolBarTextColor) + searchView.setHintTextColor(DrawableUtils.mixColor(0.5f, currentToolBarTextColor, color)) + // Change tab counter color + iBindingToolbarContent.tabsButton.apply { + textColor = currentToolBarTextColor + invalidate(); + } + // Change tool bar home button color, needed when using desktop style tabs + iBindingToolbarContent.homeButton.setColorFilter(currentToolBarTextColor) + iBindingToolbarContent.buttonActionBack.setColorFilter(currentToolBarTextColor) + iBindingToolbarContent.buttonActionForward.setColorFilter(currentToolBarTextColor) + + // Needed to delay that as otherwise disabled alpha state didn't get applied + mainHandler.postDelayed({ setupToolBarButtons() }, 500) + + // Change reload icon color + //setMenuItemColor(R.id.action_reload, currentToolBarTextColor) + // SSL status icon color + iBindingToolbarContent.addressBarInclude.searchSslStatus.setColorFilter(currentToolBarTextColor) + // Toolbar buttons filter + iBindingToolbarContent.buttonMore.setColorFilter(currentToolBarTextColor) + iBindingToolbarContent.buttonReload.setColorFilter(currentToolBarTextColor) + + // Pull to refresh spinner color also follow current theme + iTabViewContainerFront.setProgressBackgroundColorSchemeColor(color) + iTabViewContainerFront.setColorSchemeColors(currentToolBarTextColor) + + // Color also applies to the following backgrounds as they show during tool bar show/hide animation + iBinding.uiLayout.setBackgroundColor(color) + iTabViewContainerFront.setBackgroundColor(color) + // Somehow this was needed to make sure background colors are not swapped, most visible during page navigation animation + // TODO: Investigate what's going on here, as that should not be the case and could lead to other issues + iTabViewContainerBack.setBackgroundColor(color) + + + currentTabView?.let { + // Now also set WebView background color otherwise it is just white and we don't want that. + // This one is going to be a problem as it will break some websites such as bbc.com. + // Make sure we reset our background color after page load, thanks bbc.com and bbc.com/news for not defining background color. + if (iBinding.toolbarInclude.progressView.progress >= 100 + // Don't reset background color back to white on empty urls, that prevents displaying large empty white pages and blinding users in dark mode. + // When opening some download links a tab is spawned first with the download URL and later that URL is set back to null. + // Luckily our delayed call and the absence of invalidate prevents a flicker to white screen. + && !it.url.isNullOrBlank()) { + // We delay that to avoid some web sites including default startup page to flash white on app startup + mainHandler.removeCallbacks(resetBackgroundColorRunnable) + resetBackgroundColorRunnable = Runnable { + it.setBackgroundColor(Color.WHITE) + // We do not want to apply that color on the spot though. + // It does not make sense anyway since it is a delayed call. + // It also still causes a flicker notably when a tab is spawned by a download link. + //webViewEx.invalidate() + } + mainHandler.postDelayed(resetBackgroundColorRunnable, 750); + } else { + mainHandler.removeCallbacks(resetBackgroundColorRunnable) + it.setBackgroundColor(color) + // Make sure that color is applied on the spot for earlier color change when loading tabs + it.invalidate() + } + } + + // No animation for now + // Toolbar background color + iBinding.toolbarInclude.toolbarLayout.setBackgroundColor(color) + iBinding.toolbarInclude.progressView.mProgressColor = color + // Search text field color + setSearchBarColors(color) + + // Progress bar background color + DrawableUtils.mixColor(0.5f, color, Color.WHITE).let { + // Set progress bar background color making sure it isn't too bright + // That's notably making it more visible on lequipe.fr and bbc.com/sport + // We hope this is going to work with most white themed website too + if (ColorUtils.calculateLuminance(it)>0.75) { + iBinding.toolbarInclude.progressView.setBackgroundColor(Color.BLACK) + } + else { + iBinding.toolbarInclude.progressView.setBackgroundColor(it) + } + } + + // Then the color of the status bar itself + setStatusBarColor(color, currentToolBarTextColor == Color.BLACK) + + // Remove that if ever we re-enable color animation below + currentUiColor = color + // Needed for current tab color update in desktop style tabs + tabsView?.tabChanged(tabsManager.indexOfCurrentTab()) + + /* + // Define our color animation + val animation = object : Animation() { + override fun applyTransformation(interpolatedTime: Float, t: Transformation) { + val animatedColor = DrawableUtils.mixColor(interpolatedTime, currentUiColor, color) + if (shouldShowTabsInDrawer) { + backgroundDrawable.color = animatedColor + mainHandler.post { window.setBackgroundDrawable(backgroundDrawable) } + } else { + tabBackground?.tint(animatedColor) + } + currentUiColor = animatedColor + toolbar_layout.setBackgroundColor(animatedColor) + searchBackground?.background?.tint( + // Set search background a little lighter + // SL: See also Utils.mixTwoColors, why do we have those two functions? + getSearchBarColor(animatedColor) + ) + } + } + animation.duration = 300 + toolbar_layout.startAnimation(animation) + + */ + + } + + + /** + * Overrides [WebBrowser.changeToolbarBackground] + * + * Animates the color of the toolbar from one color to another. Optionally animates + * the color of the tab background, for use when the tabs are displayed on the top + * of the screen. + * + * @param favicon the Bitmap to extract the color from + * @param color HTML meta theme color. Color.TRANSPARENT if not available. + * @param tabBackground the optional LinearLayout to color + */ + override fun changeToolbarBackground(favicon: Bitmap?, color: Int, tabBackground: Drawable?) { + + val defaultColor = primaryColor + + if (!isColorMode()) { + // Put back the theme color then + applyToolbarColor(defaultColor); + } + else if (color != Color.TRANSPARENT + // Do not apply meta color if forced dark mode + && tabsManager.currentTab?.darkMode != true) + { + // We have a meta theme color specified in our page HTML, use it + applyToolbarColor(color); + } + else if (favicon==null + // Use default color if forced dark mode + || tabsManager.currentTab?.darkMode == true) + { + // No HTML meta theme color and no favicon, use app theme color then + applyToolbarColor(defaultColor); + } + else { + Palette.from(favicon).generate { palette -> + // OR with opaque black to remove transparency glitches + val color = Color.BLACK or (palette?.getVibrantColor(defaultColor) ?: defaultColor) + applyToolbarColor(color); + } + } + } + + + /** + * Set our search bar color for focused and non focused state + */ + private fun setSearchBarColors(aColor: Int) { + iBindingToolbarContent.addressBarInclude.root.apply { + val stateListDrawable = background as StateListDrawable + // Order may matter depending of states declared in our background drawable + // See: [R.drawable.card_bg_elevate] + stateListDrawable.drawableForState(android.R.attr.state_focused).tint(ThemeUtils.getSearchBarFocusedColor(aColor)) + stateListDrawable.drawableForState(android.R.attr.state_enabled).tint(ThemeUtils.getSearchBarColor(aColor)) + } + } + + + + + @ColorInt + override fun getUiColor(): Int = currentUiColor + + /** + * Called when current URL needs to be updated + */ + override fun updateUrl(url: String?, isLoading: Boolean) { + + Timber.d("updateUrl: $url") + + if (url == null) { + return + } + + val currentTab = tabsManager.currentTab + bookmarksView?.handleUpdatedUrl(url) + + val currentTitle = currentTab?.title + + if (!searchView.hasFocus()) { + Timber.d("updateUrl: $currentTitle - $url") + // Set our text but don't perform filtering + // We don't need filtering as this is just a text update from our engine rather than user performing text input and expecting search results + // Filter deactivation was introduce to prevent https://github.com/Slion/Magic/issues/557 + searchView.setText(getHeaderInfoText(userPreferences.toolbarLabel),false) + } + } + + /** + * + */ + private fun setTaskDescription() { + + // No changing task description in incognito mode + if (isIncognito()) { + return + } + + tabsManager.currentTab?.let { tab -> + if (userPreferences.taskIcon) { + //val color = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK) + val color = getColor(R.color.ic_launcher_background) + setTaskDescription(ActivityManager.TaskDescription(getHeaderInfoText(userPreferences.taskLabel),tab.favicon,color)) + } else { + setTaskDescription(ActivityManager.TaskDescription(getHeaderInfoText(userPreferences.taskLabel))) + } + } ?: setTaskDescription(ActivityManager.TaskDescription(getString(R.string.app_name))) + } + + /** + * Provide the text corresponding to the given [aInfo]. + */ + private fun getHeaderInfoText(aInfo: HeaderInfo) : String { + + tabsManager.currentTab?.let {tab -> + + if (isLoading()) { + return tab.url + } + + return when (aInfo) { + HeaderInfo.Url -> tab.url + HeaderInfo.ShortUrl -> Utils.trimmedProtocolFromURL(tab.url) + HeaderInfo.Domain -> Utils.getDisplayDomainName(tab.url) + HeaderInfo.Title -> tab.title.ifBlank { getString(R.string.untitled) } + HeaderInfo.Session -> tabsManager.iCurrentSessionName + HeaderInfo.AppName -> getString(R.string.app_name) + } + } + + // Defensive fallback to application name + return getString(R.string.app_name) + } + + + override fun updateTabNumber(number: Int) { + iBindingToolbarContent.tabsButton.updateCount(number) + } + + /** + * + */ + override fun onProgressChanged(aTab: WebPageTab, aProgress: Int) { + + if (aTab!=tabsManager.currentTab) { + return + } + + // Make sure we play forward animation when user tapped a link + if (iTappedTab == aTab && iTabAnimator==null && userPreferences.onPageStartedShowAnimation) { + animateTabFlipLeft(iTabViewContainerFront) + iTappedTab = null + } + + setIsLoading(aProgress < 100) + iBinding.toolbarInclude.progressView.progress = aProgress + } + + /** + * + */ + protected fun addItemToHistory(title: String?, url: String) { + if (url.isSpecialUrl()) { + return + } + + historyModel.visitHistoryEntry(url, title) + .subscribeOn(databaseScheduler) + .subscribe() + } + + /** + * method to generate search suggestions for the AutoCompleteTextView from + * previously searched URLs + */ + private fun initializeSearchSuggestions(getUrl: AutoCompleteTextView) { + suggestionsAdapter = SuggestionsAdapter(this, isIncognito()) + suggestionsAdapter?.onSuggestionInsertClick = { + if (it is SearchSuggestion) { + getUrl.setText(it.title) + getUrl.setSelection(it.title.length) + } else { + getUrl.setText(it.url) + getUrl.setSelection(it.url.length) + } + } + getUrl.onItemClickListener = OnItemClickListener { _, _, position, _ -> + doSearchSuggestionAction(getUrl, position) + } + getUrl.setAdapter(suggestionsAdapter) + } + + /** + * + */ + private fun doSearchSuggestionAction(getUrl: AutoCompleteTextView, position: Int) { + Timber.v("doSearchSuggestionAction") + val url = when (val selection = suggestionsAdapter?.getItem(position) as WebPage) { + is HistoryEntry, + is Bookmark.Entry -> selection.url + is SearchSuggestion -> selection.title + else -> null + } ?: return + getUrl.setText(url) + searchTheWeb(url) + inputMethodManager.hideSoftInputFromWindow(getUrl.windowToken, 0) + tabsManager.currentTab?.requestFocus() + } + + /** + * function that opens the HTML history page in the browser + */ + private fun openHistory() { + tabsManager.newTab( + historyPageInitializer, + true + ) + } + + /** + * Display downloads folder one way or another + */ + private fun openDownloads() { + startActivity(Utils.getIntentForDownloads(this, userPreferences.downloadDirectory)) + // Our built-in downloads list did not display downloaded items properly + // Not sure why, consider fixing it or just removing it altogether at some point + //tabsManager.newTab(downloadPageInitializer,true) + } + + /** + * + */ + private fun showingBookmarks() : Boolean { + return bookmarksDialog.isShowing || iBinding.drawerLayout.isDrawerOpen(getBookmarkDrawer()) + } + + /** + * + */ + private fun showingTabs() : Boolean { + return tabsDialog.isShowing || iBinding.drawerLayout.isDrawerOpen(getTabDrawer()) + } + + + /** + * helper function that opens the bookmark drawer + */ + private fun openBookmarks() { + if (showingTabs()) { + closePanelTabs() + } + + if (userPreferences.useBottomSheets) { + //createBookmarksDialog() + bookmarksDialog.show() + + // See: https://github.com/material-components/material-components-android/issues/2165 + mainHandler.postDelayed({ + bookmarksDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + }, 100) + } else { + // Define what to do once our drawer it opened + //iBinding.drawerLayout.onceOnDrawerOpened { + iBinding.drawerLayout.findViewById(R.id.list_bookmarks)?.apply { + // Focus first item in our list + findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() + } + //} + // Open bookmarks drawer + iBinding.drawerLayout.openDrawer(getBookmarkDrawer()) + } + + // Define what to do once our drawer it opened + //iBinding.drawerLayout.onceOnDrawerOpened { + bookmarksView?.iBinding?.listBookmarks?.findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() + //} + } + + /** + * + */ + private fun toggleBookmarks() { + if (showingBookmarks()) { + closePanelBookmarks() + } else { + openBookmarks() + } + } + + /** + * Open our tab list, works for both drawers and bottom sheets. + */ + private fun openTabs() { + + // Defensive, don't show empty drawers when not in use + if (!configPrefs.tabBarInDrawer) { + return + } + + if (showingBookmarks()) { + closePanelBookmarks() + } + + // Loose focus on current tab web page + // Actually this was causing our search field to gain focus on HTC One M8 - Android 6 + // currentTabView?.clearFocus() + // That's needed for focus issue when opening with tap on button + val tabListView = (tabsView as ViewGroup).findViewById(R.id.tabs_list) + tabListView?.requestFocus() + + if (userPreferences.useBottomSheets) { + //createTabsDialog() + tabsDialog.show() + // See: https://github.com/material-components/material-components-android/issues/2165 + mainHandler.postDelayed({ + tabsDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + }, 100) + } else { + // Open our tab list drawer + iBinding.drawerLayout.openDrawer(getTabDrawer()) + //iBinding.drawerLayout.onceOnDrawerOpened { + // Looks like we can do that without delays for drawers + tryScrollToCurrentTab() + //} + + } + + } + + /** + * Try to scroll to current tab and ignore any failure as this is not a critical operation. + * We did that to workaround an issue where somehow our tab list is messed up I'm guessing. + * I had that crash on start-up and could not use the app anymore, I would have had to reset all data if it was not for that fix. + */ + fun tryScrollToCurrentTab() { + try { + scrollToCurrentTab() + } catch (ex: Exception) { + Timber.w(ex,"scrollToCurrentTab exception") + } + } + + /** + * Never call that function direction. + * Just call [tryScrollToCurrentTab] instead. + */ + private fun scrollToCurrentTab() { + /*if (userPreferences.useBottomSheets && tabsView is TabsDrawerView && !(tabsDialog.isShowing && tabsDialog.behavior.state == BottomSheetBehavior.STATE_EXPANDED)) { + return + }*/ + + val tabListView = (tabsView as ViewGroup).findViewById(R.id.tabs_list) + // Set focus + // Find our recycler list view + tabListView?.apply { + // Get current tab index and layout manager + val index = tabsManager.indexOfCurrentTab() + val lm = layoutManager as LinearLayoutManager + // Check if current item is currently visible + if (lm.findFirstCompletelyVisibleItemPosition() <= index && index <= lm.findLastCompletelyVisibleItemPosition()) { + // We don't need to scroll as current item is already visible + // Just focus our current item then for best keyboard navigation experience + findViewHolderForAdapterPosition(tabsManager.indexOfCurrentTab())?.itemView?.requestFocus() + } else { + // Our current item is not completely visible, we need to scroll then + // Once scroll is complete we will focus our current item + onceOnScrollStateIdle { findViewHolderForAdapterPosition(tabsManager.indexOfCurrentTab())?.itemView?.requestFocus() } + // Trigger scroll + smoothScrollToPosition(index) + } + } + } + + /** + * Toggle tab list visibility + */ + private fun toggleTabs() { + if (showingTabs()) { + closePanelTabs() + } else { + openTabs() + } + } + + /** + * Toggle tab list visibility + */ + private fun toggleSessions() { + // isShowing always return false for some reason + // Therefore toggle is not working however one can use Esc to close menu. + // TODO: Fix that at some point + if (iMenuSessions.isShowing) { + iMenuSessions.dismiss() + } else { + showSessions() + } + } + + + /** + * This method closes any open drawer and executes the runnable after the drawers are closed. + * + * @param runnable an optional runnable to run after the drawers are closed. + */ + protected fun closePanels() { + closePanelTabs() + closePanelBookmarks() + } + + override fun setForwardButtonEnabled(enabled: Boolean) { + iMenuMain.iBinding.menuShortcutForward.isEnabled = enabled + iMenuWebPage.iBinding.menuShortcutForward.isEnabled = enabled + tabsView?.setGoForwardEnabled(enabled) + } + + override fun setBackButtonEnabled(enabled: Boolean) { + iMenuMain.iBinding.menuShortcutBack.isEnabled = enabled + iMenuWebPage.iBinding.menuShortcutBack.isEnabled = enabled + tabsView?.setGoBackEnabled(enabled) + } + + /** + * opens a file chooser + * param ValueCallback is the message from the WebView indicating a file chooser + * should be opened + */ + override fun openFileChooser(uploadMsg: ValueCallback) { + uploadMessageCallback = uploadMsg + startActivityForResult(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + }, getString(R.string.title_file_chooser)), FILE_CHOOSER_REQUEST_CODE) + } + + + /** + * used to allow uploading into the browser + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + if (requestCode == FILE_CHOOSER_REQUEST_CODE) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val result = if (intent == null || resultCode != Activity.RESULT_OK) { + null + } else { + intent.data + } + + uploadMessageCallback?.onReceiveValue(result) + uploadMessageCallback = null + } else { + val results: Array? = if (resultCode == Activity.RESULT_OK) { + if (intent == null) { + // If there is not data, then we may have taken a photo + cameraPhotoPath?.let { arrayOf(it.toUri()) } + } else { + intent.dataString?.let { arrayOf(it.toUri()) } + } + } else { + null + } + + filePathCallback?.onReceiveValue(results) + filePathCallback = null + } + } else { + super.onActivityResult(requestCode, resultCode, intent) + } + } + + /** + * SL: This implementation is really strange. + * It looks like that's being called from LightningChromeClient.onShowFileChooser. + * My understanding is that this is a WebView callback from web forms asking user to pick a file. + * So why do we create an image file in there? That does not make sense to me. + */ + override fun showFileChooser(filePathCallback: ValueCallback>) { + this.filePathCallback?.onReceiveValue(null) + this.filePathCallback = filePathCallback + + // Create the File where the photo should go + val intentArray: Array = try { + arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra("PhotoPath", cameraPhotoPath) + putExtra( + MediaStore.EXTRA_OUTPUT, + Uri.fromFile(Utils.createImageFile().also { file -> + cameraPhotoPath = "file:${file.absolutePath}" + }) + ) + }) + } catch (ex: IOException) { + // Error occurred while creating the File + Timber.d("Unable to create Image File", ex) + emptyArray() + } + + startActivityForResult(Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + }) + putExtra(Intent.EXTRA_TITLE, "Image Chooser") + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray) + }, FILE_CHOOSER_REQUEST_CODE) + } + + override fun onShowCustomView(view: View, callback: CustomViewCallback, requestedOrientation: Int) { + val currentTab = tabsManager.currentTab + if (customView != null) { + try { + callback.onCustomViewHidden() + } catch (e: Exception) { + Timber.d("Error hiding custom view", e) + } + + return + } + + try { + view.keepScreenOn = true + } catch (e: SecurityException) { + Timber.d("WebView is not allowed to keep the screen on") + } + + originalOrientation = getRequestedOrientation() + customViewCallback = callback + customView = view + + setRequestedOrientation(requestedOrientation) + val decorView = window.decorView as FrameLayout + + fullscreenContainerView = FrameLayout(this) + fullscreenContainerView?.setBackgroundColor(ContextCompat.getColor(this, R.color.black)) + if (view is FrameLayout) { + val child = view.focusedChild + if (child is VideoView) { + videoView = child + child.setOnErrorListener(VideoCompletionListener()) + child.setOnCompletionListener(VideoCompletionListener()) + } + } else if (view is VideoView) { + videoView = view + view.setOnErrorListener(VideoCompletionListener()) + view.setOnCompletionListener(VideoCompletionListener()) + } + decorView.addView(fullscreenContainerView, COVER_SCREEN_PARAMS) + fullscreenContainerView?.addView(customView, COVER_SCREEN_PARAMS) + decorView.requestLayout() + setFullscreen(enabled = true, immersive = true) + currentTab?.setVisibility(INVISIBLE) + } + + override fun onHideCustomView() { + val currentTab = tabsManager.currentTab + if (customView == null || customViewCallback == null || currentTab == null) { + if (customViewCallback != null) { + try { + customViewCallback?.onCustomViewHidden() + } catch (e: Exception) { + Timber.d("Error hiding custom view", e) + } + + customViewCallback = null + } + return + } + Timber.d("onHideCustomView") + currentTab.setVisibility(VISIBLE) + currentTab.requestFocus() + try { + customView?.keepScreenOn = false + } catch (e: SecurityException) { + Timber.d("WebView is not allowed to keep the screen on") + } + + setFullscreenIfNeeded() + if (fullscreenContainerView != null) { + val parent = fullscreenContainerView?.parent as ViewGroup + parent.removeView(fullscreenContainerView) + fullscreenContainerView?.removeAllViews() + } + + fullscreenContainerView = null + customView = null + + Timber.d("VideoView is being stopped") + videoView?.stopPlayback() + videoView?.setOnErrorListener(null) + videoView?.setOnCompletionListener(null) + videoView = null + + try { + customViewCallback?.onCustomViewHidden() + } catch (e: Exception) { + Timber.d("Error hiding custom view", e) + } + + customViewCallback = null + requestedOrientation = originalOrientation + } + + private inner class VideoCompletionListener : MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { + + override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean = false + + override fun onCompletion(mp: MediaPlayer) = onHideCustomView() + + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + Timber.d("onWindowFocusChanged") + if (hasFocus) { + setFullscreen(hideStatusBar, isImmersiveMode) + } + } + + /** + * TODO: Is that being used? + */ + override fun onBackButtonPressed() { + if (closeTabsPanelIfOpen()) { + val currentTab = tabsManager.currentTab + if (currentTab?.canGoBack() == true) { + currentTabGoBack() + } else if (currentTab != null) { + tabsManager.let { tabsManager.deleteTab(it.positionOf(currentTab)) } + } + } else if (closeBookmarksPanelIfOpen()) { + // Don't do anything other than close the bookmarks drawer when the activity is being + // delegated to. + } + } + + override fun onForwardButtonPressed() { + val currentTab = tabsManager.currentTab + if (currentTab?.canGoForward() == true) { + currentTabGoForward() + closePanels() + } + } + + override fun onHomeButtonPressed() { + executeAction(R.id.action_show_homepage) + } + + + private val fullScreenFlags = (SYSTEM_UI_FLAG_LAYOUT_STABLE + or SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or SYSTEM_UI_FLAG_HIDE_NAVIGATION + or SYSTEM_UI_FLAG_FULLSCREEN + or SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + + + /** + * Hide the status bar according to orientation and user preferences + */ + private fun setFullscreenIfNeeded() { + setFullscreen(configPrefs.hideStatusBar, false) + } + + + var statusBarHidden = false + + /** + * This method sets whether or not the activity will display + * in full-screen mode (i.e. the ActionBar will be hidden) and + * whether or not immersive mode should be set. This is used to + * set both parameters correctly as during a full-screen video, + * both need to be set, but other-wise we leave it up to user + * preference. + * + * @param enabled true to enable full-screen, false otherwise + * @param immersive true to enable immersive mode, false otherwise + */ + private fun setFullscreen(enabled: Boolean, immersive: Boolean) { + + // In theory we should be able to use those new APIs + //WindowCompat.getInsetsController(window,window.decorView).show(WindowInsetsCompat.Type.systemBars()) + + hideStatusBar = enabled + isImmersiveMode = immersive + val window = window + val decor = window.decorView + if (enabled) { + if (immersive) { + decor.systemUiVisibility = decor.systemUiVisibility or fullScreenFlags + } else { + decor.systemUiVisibility = decor.systemUiVisibility and fullScreenFlags.inv() + } + window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN) + statusBarHidden = true + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + decor.systemUiVisibility = decor.systemUiVisibility and fullScreenFlags.inv() + statusBarHidden = false + } + + // Keep our bottom sheets dialog in sync + tabsDialog.window?.decorView?.systemUiVisibility = window.decorView.systemUiVisibility + tabsDialog.window?.setFlags(window.attributes.flags, WindowManager.LayoutParams.FLAG_FULLSCREEN) + bookmarksDialog.window?.decorView?.systemUiVisibility = window.decorView.systemUiVisibility + bookmarksDialog.window?.setFlags(window.attributes.flags, WindowManager.LayoutParams.FLAG_FULLSCREEN) + + + } + + /** + * This method handles the JavaScript callback to create a new tab. + * Basically this handles the event that JavaScript needs to create + * a popup. + * + * @param resultMsg the transport message used to send the URL to + * the newly created WebView. + */ + override fun onCreateWindow(resultMsg: Message) { + tabsManager.newTab(ResultMessageInitializer(resultMsg), true) + } + + /** + * Closes the specified [WebPageTab]. This implements + * the JavaScript callback that asks the tab to close itself and + * is especially helpful when a page creates a redirect and does + * not need the tab to stay open any longer. + * + * @param tab the [WebPageTab] to close, delete it. + */ + override fun onCloseWindow(tab: WebPageTab) { + tabsManager.deleteTab(tabsManager.positionOf(tab)) + } + + /** + * Hide the ActionBar if we are in full-screen + */ + override fun hideActionBar() { + if (isFullScreen) { + doHideToolBar() + } + } + + /** + * Display the ActionBar if it was hidden + */ + override fun showActionBar() { + Timber.d("showActionBar") + iBinding.toolbarInclude.toolbarLayout.visibility = View.VISIBLE + } + + private fun doHideToolBar() { iBinding.toolbarInclude.toolbarLayout.visibility = View.GONE } + private fun isToolBarVisible() = iBinding.toolbarInclude.toolbarLayout.visibility == View.VISIBLE + + private fun toggleToolBar() : Boolean + { + return if (isToolBarVisible()) { + doHideToolBar() + currentTabView?.requestFocus() + false + } else { + showActionBar() + iBindingToolbarContent.buttonMore.requestFocus() + true + } + } + + + override fun handleBookmarksChange() { + val currentTab = tabsManager.currentTab + if (currentTab != null && currentTab.url.isBookmarkUrl()) { + currentTab.loadBookmarkPage() + } + if (currentTab != null) { + bookmarksView?.handleUpdatedUrl(currentTab.url) + } + suggestionsAdapter?.refreshBookmarks() + } + + override fun handleDownloadDeleted() { + val currentTab = tabsManager.currentTab + if (currentTab != null && currentTab.url.isDownloadsUrl()) { + currentTab.loadDownloadsPage() + } + if (currentTab != null) { + bookmarksView?.handleUpdatedUrl(currentTab.url) + } + } + + override fun handleBookmarkDeleted(bookmark: Bookmark) { + bookmarksView?.handleBookmarkDeleted(bookmark) + handleBookmarksChange() + } + + override fun handleNewTab(newTabType: LightningDialogBuilder.NewTab, url: String) { + val urlInitializer = UrlInitializer(url) + when (newTabType) { + LightningDialogBuilder.NewTab.FOREGROUND -> tabsManager.newTab(urlInitializer, true) + LightningDialogBuilder.NewTab.BACKGROUND -> tabsManager.newTab(urlInitializer, false) + LightningDialogBuilder.NewTab.INCOGNITO -> { + closePanels() + val intent = IncognitoActivity.createIntent(this, url.toUri()) + startActivity(intent) + overridePendingTransition(R.anim.slide_up_in, R.anim.fade_out_scale) + } + } + } + + + //var refreshButtonResId = R.drawable.ic_action_refresh + + /** + * This method lets the search bar know that the page is currently loading + * and that it should display the stop icon to indicate to the user that + * pressing it stops the page from loading + * + * TODO: Should we just have two buttons and manage their visibility? + * That should also animate the transition I guess. + */ + private fun setIsLoading(isLoading: Boolean) { + if (!searchView.hasFocus()) { + iBindingToolbarContent.addressBarInclude.searchSslStatus.updateVisibilityForContent() + } + + // Set stop or reload icon according to current load status + //setMenuItemIcon(R.id.action_reload, if (isLoading) R.drawable.ic_action_delete else R.drawable.ic_action_refresh) + updateReloadButton() + + // That fancy animation would be great but somehow it looks like it is causing issues making the button unresponsive. + // I'm guessing it is conflicting with animations from layout change. + // Animations on Android really are a pain in the ass, half baked crappy implementations. + /* + button_reload.let { + val imageRes = if (isLoading) R.drawable.ic_action_delete else R.drawable.ic_action_refresh + // Only change our image if needed otherwise we animate for nothing + // Therefore first check if the selected image is already displayed + if (refreshButtonResId != imageRes){ + refreshButtonResId = imageRes + if (it.animation==null) { + val transition = AnimationUtils.createRotationTransitionAnimation(it, refreshButtonResId) + it.startAnimation(transition) + } + else{ + button_reload.setImageResource(imageRes); + } + } + } + */ + + setupPullToRefresh(resources.configuration) + } + + + /** + * handle presses on the refresh icon in the search bar, if the page is + * loading, stop the page, if it is done loading refresh the page. + * See setIsFinishedLoading and setIsLoading for displaying the correct icon + */ + private fun refreshOrStop() { + val currentTab = tabsManager.currentTab + if (currentTab != null) { + if (currentTab.progress < 100) { + currentTab.stopLoading() + } else { + currentTab.reload() + } + } + } + + /** + * Handle the click event for the views that are using + * this class as a click listener. This method should + * distinguish between the various views using their IDs. + * + * @param v the view that the user has clicked + */ + override fun onClick(v: View) { + val currentTab = tabsManager.currentTab ?: return + when (v.id) { + R.id.home_button -> currentTab.apply { requestFocus(); loadHomePage() } + // When not using drawer for tabs that button is used to show webpage menu + R.id.tabs_button -> if (configPrefs.tabBarInDrawer) openTabs() else {showMenuWebPage()} + R.id.button_reload -> refreshOrStop() + R.id.button_next -> currentTab.findNext() + R.id.button_back -> currentTab.findPrevious() + R.id.button_quit -> { + currentTab.clearFind() + iBinding.findInPageInclude.root.isVisible = false + // Hide software keyboard + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(iBinding.findInPageInclude.searchQuery.windowToken, 0) + //tabsManager.currentTab?.requestFocus() + } + } + } + + /** + * Handle the callback that permissions requested have been granted or not. + * This method should act upon the results of the permissions request. + * + * @param requestCode the request code sent when initially making the request + * @param permissions the array of the permissions that was requested + * @param grantResults the results of the permissions requests that provides + * information on whether the request was granted or not + */ + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { +// PermissionsManager.getInstance().notifyPermissionsChange(permissions, grantResults) + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + /** + * If the [drawer] is open, close it and return true. Return false otherwise. + */ + private fun closeTabsPanelIfOpen(): Boolean = + if (showingTabs()) { + closePanelTabs() + true + } else { + false + } + + /** + * If the [drawer] is open, close it and return true. Return false otherwise. + */ + private fun closeBookmarksPanelIfOpen(): Boolean = + if (showingBookmarks()) { + closePanelBookmarks() + true + } else { + false + } + + + /** + * Welcome user after first installation. + */ + private fun welcomeToMagic() { + MaterialAlertDialogBuilder(this) + .setCancelable(true) + .setTitle(R.string.title_welcome) + .setMessage(R.string.message_welcome) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { _, _ -> + val url = getString(R.string.url_app_home_page) + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(url) + // Not sure that does anything + i.putExtra("SOURCE", "SELF") +// i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + i.setPackage(packageName) + startActivity(i) + } + .resizeAndShow() + } + + + /** + * Notify user about application update. + */ + private fun notifyVersionUpdate() { + // TODO: Consider using snackbar instead to be less intrusive, make it a settings option? + MaterialAlertDialogBuilder(this) + .setCancelable(true) + .setTitle(R.string.title_updated) + .setMessage(getString(R.string.message_updated, BuildConfig.VERSION_NAME)) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { _, _ -> val url = getString(R.string.url_app_updates) + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(url) + // Not sure that does anything + i.putExtra("SOURCE", "SELF") + i.setPackage(packageName) + startActivity(i)} + .resizeAndShow() + } + + + /** + * Check for update on slions.net. + */ + private fun checkForUpdates() { + val url = getString(R.string.slions_update_check_url) + // Request a JSON object response from the provided URL. + val request = object: JsonObjectRequest( + Request.Method.GET, url, null, + Response.Listener { response -> + + val latestVersion = response.getJSONArray("versions").getJSONObject(0).getString("version_string") + if (latestVersion != BuildConfig.VERSION_NAME) { + // We have an update available, tell our user about it + makeSnackbar( + getString(R.string.update_available) + " - v" + latestVersion, 5000, if (configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM) //Snackbar.LENGTH_LONG + .setAction(R.string.show, View.OnClickListener { + val url = getString(R.string.url_app_home_page) + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(url) + // Not sure that does anything + i.putExtra("SOURCE", "SELF") + i.setPackage(packageName) + startActivity(i) + }).show() + } + + //Log.d(TAG,response.toString()) + }, + Response.ErrorListener { error: VolleyError -> + // Just ignore error for background update check + // Use the following for network status code + // Though networkResponse can be null in flight mode for instance + // error.networkResponse.statusCode.toString() + + } + ){ + @Throws(AuthFailureError::class) + override fun getHeaders(): Map { + val params: MutableMap = HashMap() + // Provide here slions.net API key as part of this requests HTTP headers + params["XF-Api-Key"] = getString(R.string.slions_api_key) + return params + } + } + + request.tag = TAG + // Add the request to the RequestQueue. + queue.add(request) + } + + var iLastTouchUpPosition: Point = Point() + + /** + * TODO: Unused + */ + override fun dispatchTouchEvent(anEvent: MotionEvent?): Boolean { + + when (anEvent?.action) { + MotionEvent.ACTION_UP -> { + iLastTouchUpPosition.x = anEvent.x.toInt() + iLastTouchUpPosition.y = anEvent.y.toInt() + + } + } + return super.dispatchTouchEvent(anEvent) + } + + /** + * Implement [WebBrowser.onMaxTabReached] + */ + override fun onMaxTabReached() { + // Show a message telling the user to contribute. + // It provides a link to our settings Contribute section. + makeSnackbar( + getString(R.string.max_tabs), 10000, if (configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM) //Snackbar.LENGTH_LONG + .setAction(R.string.show, View.OnClickListener { + // We want to launch our settings activity + val i = Intent(this, SettingsActivity::class.java) + /** See [SettingsActivity.onResume] for details of how this is handled on the other side */ + // Tell our settings activity to load our Contribute/Sponsorship fragment + i.putExtra(SETTINGS_CLASS_NAME, SponsorshipSettingsFragment::class.java.name) + startActivity(i) + }).show() + } + + /** + * Implement [WebBrowser.setAddressBarText] + */ + override fun setAddressBarText(aText: String) { + Timber.d("setAddressBarText: $aText") + mainHandler.postDelayed({ + Timber.d("setAddressBarText: $aText") + // Emulate tap to open up soft keyboard if needed + searchView.simulateTap() + searchView.setText(aText) + searchView.selectAll() + // Large one second delay to be safe otherwise we no-op or find the UI in a weird state + }, 1000) + } + + /** + * + */ + private fun stringContainsItemFromList(inputStr: String, items: Array): Boolean { + for (i in items.indices) { + if (inputStr.contains(items[i])) { + return true + } + } + return false + } + + /** + * Show the page tools dialog. + */ + @SuppressLint("CutPasteId") + @Suppress("RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") + fun showPageToolsDialog(position: Int) { + if (position < 0) { + return + } + val currentTab = tabsManager.currentTab ?: return + + BrowserDialog.showWithIcons(this, this.getString(R.string.dialog_tools_title), + /* + DialogItem( + icon = this.drawable(R.drawable.ic_baseline_code_24), + title = R.string.page_source) { + currentTab.webView?.evaluateJavascript("""(function() { + return "" + document.getElementsByTagName('html')[0].innerHTML + ""; + })()""".trimMargin()) { + // Hacky workaround for weird WebView encoding bug + var name = it?.replace("\\u003C", "<") + name = name?.replace("\\n", System.getProperty("line.separator").toString()) + name = name?.replace("\\t", "") + name = name?.replace("\\\"", "\"") + name = name?.substring(1, name.length - 1) + + val builder = MaterialAlertDialogBuilder(this) + val inflater = this.layoutInflater + builder.setTitle(R.string.page_source) + val dialogLayout = inflater.inflate(R.layout.dialog_view_source, null) + val editText = dialogLayout.findViewById(R.id.dialog_multi_line) + editText.setText(name, 1) + builder.setView(dialogLayout) + builder.setNegativeButton(R.string.action_cancel) { _, _ -> } + builder.setPositiveButton(R.string.action_ok) { _, _ -> + editText.setText(editText.text?.toString()?.replace("\'", "\\\'"), 1) + currentTab.loadUrl("javascript:(function() { document.documentElement.innerHTML = '" + editText.text.toString() + "'; })()") + } + builder.show() + } + },*/ + DialogItem( + icon= this.drawable(R.drawable.ic_script_add), + title = R.string.inspect){ + val builder = MaterialAlertDialogBuilder(this) + val inflater = this.layoutInflater + builder.setTitle(R.string.inspect) + val dialogLayout = inflater.inflate(R.layout.dialog_code_editor, null) + val codeView: CodeView = dialogLayout.findViewById(R.id.dialog_multi_line) + codeView.text.toString() + builder.setView(dialogLayout) + builder.setNegativeButton(R.string.action_cancel) { _, _ -> } + builder.setPositiveButton(R.string.action_ok) { _, _ -> currentTab.loadUrl("javascript:(function() {" + codeView.text.toString() + "})()") } + builder.show() + }, + DialogItem( + icon = this.drawable(R.drawable.cookie_outline), + title = R.string.edit_cookies) { + val cookieManager = CookieManager.getInstance() + if (cookieManager.getCookie(currentTab.url) != null) { + val builder = MaterialAlertDialogBuilder(this) + val inflater = this.layoutInflater + builder.setTitle(R.string.site_cookies) + val dialogLayout = inflater.inflate(R.layout.dialog_code_editor, null) + val codeView: CodeView = dialogLayout.findViewById(R.id.dialog_multi_line) + codeView.setText(cookieManager.getCookie(currentTab.url)) + builder.setView(dialogLayout) + builder.setNegativeButton(R.string.action_cancel) { _, _ -> } + builder.setPositiveButton(R.string.action_ok) { _, _ -> + val cookiesList = codeView.text.toString().split(";") + cookiesList.forEach { item -> + CookieManager.getInstance().setCookie(currentTab.url, item) + } + } + builder.show() + } + + }, + DialogItem( + icon = this.drawable(R.drawable.ic_tabs), + title = R.string.close_tab) { + tabsManager.deleteTab(position) + }, + DialogItem( + icon = this.drawable(R.drawable.ic_delete_forever), + title = R.string.close_all_tabs) { + tabsManager.closeAllOtherTabs() + }, + DialogItem( + icon = this.drawable(R.drawable.round_clear_24), + title = R.string.exit, onClick = this::closeBrowser) + ) + } + + + + @RequiresApi(Build.VERSION_CODES.N) + lateinit var iShortcuts: Shortcuts + + /** + * + */ + private fun createKeyboardShortcuts() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + iShortcuts = Shortcuts(this) + } + } + + /** + * Publish keyboard shortcuts so that user can see them when doing Meta+/? + */ + @RequiresApi(Build.VERSION_CODES.N) + override fun onProvideKeyboardShortcuts(data: MutableList, menu: Menu?, deviceId: Int) { + + // Publish our shortcuts, could publish a different list based on current state too + if (iShortcuts.iList.isNotEmpty()) { + data.add(KeyboardShortcutGroup(getString(R.string.app_name), iShortcuts.iList)) + } + + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + + /** + * Needed to have animations while navigating our settings. + * Also used to back up our stack. + * See [PreferenceFragmentCompat.onPreferenceTreeClick]. + */ + @SuppressLint("PrivateResource") + override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, preference: Preference): Boolean { + val fragmentManager: FragmentManager = caller.parentFragmentManager + + // No actual fragment specified, just a back action + if (preference.fragment == "back") { + if (fragmentManager.backStackEntryCount >=1) { + // Go back to previous fragment if any + fragmentManager.popBackStack() + } else { + // Close our bottom sheet if not previous fragment + // Needed for the case where we jump directly to a domain settings without going through option + // Notably happening when security error is set to no and snackbar action is shown + // Actually should not be needed now that we hide the back button in that case. + iBottomSheet.dismiss() + } + + return true + } + + // Launch specified fragment + val args: Bundle = preference.extras + val fragment = fragmentManager.fragmentFactory.instantiate(classLoader, preference.fragment!!) + fragment.arguments = args + fragmentManager.beginTransaction() + // Use standard bottom sheet animations + .setCustomAnimations(com.google.android.material.R.anim.design_bottom_sheet_slide_in, + com.google.android.material.R.anim.design_bottom_sheet_slide_out, + com.google.android.material.R.anim.design_bottom_sheet_slide_in, + com.google.android.material.R.anim.design_bottom_sheet_slide_out) + .replace((caller.requireView().parent as View).id, fragment) + .addToBackStack(null) + .commit() + return true; + } + + companion object { + + private const val TAG = "BrowserActivity" + + const val INTENT_PANIC_TRIGGER = "info.guardianproject.panic.action.TRIGGER" + + private const val FILE_CHOOSER_REQUEST_CODE = 1111 + + // Constant + private val MATCH_PARENT = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + private val COVER_SCREEN_PARAMS = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + + } + +} + diff --git a/app/src/main/java/com/lin/magic/adblock/AbpBlocker.kt b/app/src/main/java/com/lin/magic/adblock/AbpBlocker.kt new file mode 100644 index 00000000..6e95ee00 --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/AbpBlocker.kt @@ -0,0 +1,266 @@ +package com.lin.magic.adblock + +import android.net.Uri +import jp.hazuki.yuzubrowser.adblock.core.ContentRequest +import jp.hazuki.yuzubrowser.adblock.core.FilterContainer +import jp.hazuki.yuzubrowser.adblock.filter.ContentFilter +import jp.hazuki.yuzubrowser.adblock.filter.abp.* +import jp.hazuki.yuzubrowser.adblock.filter.unified.* + +class AbpBlocker( + private val abpUserRules: AbpUserRules?, + private val filterContainers: Map +) { + + // return null if ok, BlockerResponse if blocked or modified + fun shouldBlock(request: ContentRequest): BlockerResponse? { + + // first check user rules, they have highest priority + abpUserRules?.getResponse(request)?.let { response -> + // no pattern needed if blocked by user + return if (response) BlockResponse(USER_BLOCKED, "") + else null // don't modify anything that is explicitly blocked or allowed by the user? + } + + // then 'important' filters + filterContainers[ABP_PREFIX_IMPORTANT_ALLOW]!![request]?.let { return allowOrModify(request) } + filterContainers[ABP_PREFIX_IMPORTANT]!![request]?.let { + // https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#important + // -> "The $important modifier will be ignored if a document-level exception rule is applied to the document." + return if (filterContainers[ABP_PREFIX_ALLOW]!![request]?.let { allowFilter -> + allowFilter.contentType and ContentRequest.TYPE_DOCUMENT != 0 && // document-level allowed + allowFilter.contentType and ContentRequest.INVERSE == 0 // but not simply everything allowed + } == true) // we have a document-level exception + null + else blockOrRedirect(request, ABP_PREFIX_IMPORTANT, it.pattern) + } + + // check normal blocklist + filterContainers[ABP_PREFIX_ALLOW]!![request]?.let { return allowOrModify(request) } + filterContainers[ABP_PREFIX_DENY]!![request]?.let { return blockOrRedirect(request, ABP_PREFIX_DENY, it.pattern) } + + // not explicitly allowed or blocked + return allowOrModify(request) + } + + private fun blockOrRedirect(request: ContentRequest, prefix: String, pattern: String): BlockerResponse { + val modify = filterContainers[ABP_PREFIX_REDIRECT]!!.getAll(request) + modify.removeModifyExceptions(request, ABP_PREFIX_REDIRECT_EXCEPTION) + return if (modify.isEmpty()) + BlockResponse(prefix, pattern) + else { + modify.map { (it.modify as RedirectFilter).withPriority() } + .maxByOrNull { it.second } + ?.let { BlockResourceResponse(it.first) } + ?: BlockResponse(prefix, pattern) + } + } + + private fun allowOrModify(request: ContentRequest): ModifyResponse? { + // check whether response should be modified + // careful: we need to get ALL matching modify filters, not just any (like it's done for block and allow decisions) + val modifyFilters = filterContainers[ABP_PREFIX_MODIFY]!!.getAll(request) + if (modifyFilters.isEmpty()) return null + + if (request.url.encodedQuery == null) { + // if no parameters, remove all removeparam filters + modifyFilters.removeAll { RemoveparamFilter::class.java.isAssignableFrom(it.modify!!::class.java) } + if (modifyFilters.isEmpty()) return null + } + val remainingExceptions = modifyFilters.removeModifyExceptions(request, ABP_PREFIX_MODIFY_EXCEPTION) + + // there can be multiple valid filters, and all should be applied if possible + // just do one after the other + // but since WebResourceRequest can't be changed and returned to WebView, it must all happen within getModifiedResponse() + return getModifiedResponse(request, modifyFilters.mapNotNull { it.modify }.toMutableList(), remainingExceptions) + } + + // how exceptions work: (adguard removeparam documentation is useful: https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters) + // without parameter (i.e. empty), all filters of that type (removeparam, csp, redirect,...) are invalid + // with parameter, only same type and same parameter are considered invalid + // return exceptions that can't be decided at this point, e.g. $removeparam and exception $removeparam=p + private fun MutableList.removeModifyExceptions(request: ContentRequest, prefix: String): List { + val modifyExceptions = filterContainers[prefix]!!.getAll(request) + + for (i in (0 until modifyExceptions.size).reversed()) { + if (modifyExceptions[i].modify!!.parameter == null) { + // no parameter -> remove all modify of same type (not same prefix, because of RemoveParamRegexFilter!) + removeAll { modifyExceptions[i].modify!!::class.java.isAssignableFrom(it.modify!!::class.java) } + modifyExceptions.removeAt(i) // not needed any more + } + else { // remove exact matches + removeAll { it.modify == modifyExceptions[i].modify } + // but don't remove from list, e.g. removeparam and exception $removeparam=a + // exceptions for header-modifying filters are not handled properly, but at least + + // handle $csp exception and similar + if (modifyExceptions[i].modify!! is ResponseHeaderFilter && modifyExceptions[i].modify!!.parameter?.contains(':') == false) { + val header = modifyExceptions[i].modify!!.parameter!! + removeAll { it.modify is ResponseHeaderFilter && it.modify!!.parameter?.startsWith(header) == true && it.modify!!.inverse == modifyExceptions[i].modify!!.inverse } + modifyExceptions.removeAt(i) // not needed any more + } + // same thing for requestheaders, not sure though whether it is used at all + else if (modifyExceptions[i].modify!! is RequestHeaderFilter && modifyExceptions[i].modify!!.parameter?.contains(':') == false) { + val header = modifyExceptions[i].modify!!.parameter!! + removeAll { it.modify is RequestHeaderFilter && it.modify!!.parameter?.startsWith(header) == true && it.modify!!.inverse == modifyExceptions[i].modify!!.inverse } + modifyExceptions.removeAt(i) // not needed any more + } + } + } + return modifyExceptions.mapNotNull { it.modify } + } + + companion object { + // this needs to be fast, the adguard url tracking list has quite a few options acting on all urls! + // e.g. removing the fbclid parameter added by facebook + private fun getModifiedResponse(request: ContentRequest, filters: MutableList, remainingExceptions: List): ModifyResponse? { + // we can't simply modify the request, so do what the request wants, but modify + // then deliver what we got as response + // like in https://stackoverflow.com/questions/7610790/add-custom-headers-to-webview-resource-requests-android + + // apply removeparam + val parameters = getModifiedParameters(request.url, filters, remainingExceptions) + filters.removeAll { RemoveparamFilter::class.java.isAssignableFrom(it::class.java) } + if (parameters == null && filters.isEmpty()) return null + + // apply request header modifying filters + val requestHeaders = request.headers + val requestHeaderSize = requestHeaders.size + filters.forEach { + // add only if not blocked by exception + if (it is RequestHeaderFilter) { + if (it.inverse) // 'inverse' is the same as 'remove' here + requestHeaders.removeHeader(it.parameter!!) // can't have null parameter + else + requestHeaders.addHeader(it.parameter!!) + } + } + filters.removeAll { it is RequestHeaderFilter } + if (parameters == null && filters.isEmpty() && requestHeaderSize == requestHeaders.size) + return null + + // gather headers to add/remove from remaining filters + val addHeaders = mutableMapOf() + val removeHeaders = mutableListOf() + filters.forEach { + when (it) { + // add only if not blocked by exception + is ResponseHeaderFilter -> { + if (it.inverse) // 'inverse' is the same as 'remove' here + removeHeaders.add(it.parameter!!) // can't have null parameter + else { + addHeaders.addHeader(it.parameter!!) + } + } + // else -> what do? this should never happen, maybe log? + } + } + val newUrl = if (parameters == null) + request.url.toString() + else + request.url.toString().substringBefore('?').substringBefore('#') + // url without parameters and fragment + parameterString(parameters) + // add modified parameters + (request.url.fragment?.let {"#$it"} ?: "") // add fragment + + return ModifyResponse(newUrl, request.method, requestHeaders, addHeaders, removeHeaders) + } + + // applies filters to parameters and returns remaining parameters + // returns null of parameters are not modified + private fun getModifiedParameters(url: Uri, filters: List, exceptions: List): Map? { + val parameters = url.getQueryParameterMap() + var changed = false + val removeParamExceptions = exceptions.mapNotNull { + if (RemoveparamFilter::class.java.isAssignableFrom(it::class.java)) it + else null + } + filters.forEach { modify -> + if (RemoveparamFilter::class.java.isAssignableFrom(modify::class.java)) { + changed = changed or parameters.entries.removeAll { parameter -> + modify.matchParameter(parameter.key) + && removeParamExceptions.all { !it.matchParameter(parameter.key) } + } + } + } + return if (changed) parameters else null + } + + private fun ModifyFilter.matchParameter(parm: String): Boolean { + return when (this) { + is RemoveparamRegexFilter -> regex.containsMatchIn(parm) xor inverse + is RemoveparamFilter -> (parameter == null || parameter == parm) xor inverse + else -> false + } + } + + private fun parameterString(parameters: Map) = + if (parameters.isEmpty()) "" + else "?" + parameters.entries.joinToString("&") { it.key + "=" + it.value } + + // string must look like: User-Agent: Mozilla/5.0 + private fun MutableMap.addHeader(headerAndValue: String) = + addHeader( + MapEntry( + headerAndValue.substringBefore(':').trim(), + headerAndValue.substringAfter(':').trim() + ) + ) + + class MapEntry(override val key: String, override val value: String) : Map.Entry + + fun MutableMap.addHeader(headerAndValue: Map.Entry) { + keys.forEach { + // header names are case insensitive, but we want to modify as little as possible + if (it.lowercase() == headerAndValue.key.lowercase()) { + put(it, get(it) + "; " + headerAndValue.value) + return + } + } + put(headerAndValue.key, headerAndValue.value) + } + + fun MutableMap.removeHeader(header: String) { + keys.forEach { + if (it.lowercase() == header.lowercase()) + remove(it) + } + } + + // redirect filters are only applied (or even checked!) if request is blocked! + // usually, a matching block filter is created with redirect filter, but not necessarily + private fun RedirectFilter.withPriority(): Pair { + val split = parameter!!.indexOf(':') + return if (split > -1) + Pair(parameter.substring(0,split), parameter.substring(split+1).toInt()) + else + Pair(parameter, 0) + } + + // using query and not decoding is twice as fast + // any problems? better leave as is, overall this is so fast at doesn't matter anyway + // using LinkedHashMap to keep original order + fun Uri.getQueryParameterMap(): LinkedHashMap { + // using some code from android.net.uri.getQueryParameters() + val query = encodedQuery ?: return linkedMapOf() + val parameters = linkedMapOf() + var start = 0 + do { + val next = query.indexOf('&', start) + val end = if (next == -1) query.length else next + var separator = query.indexOf('=', start) + if (separator > end || separator == -1) { + separator = end + } + parameters[Uri.decode(query.substring(start, separator))] = // parameter name + Uri.decode( + query.substring( + if (separator < end) separator + 1 else end, + end + ) + ) // parameter value + start = end + 1 + } while (start < query.length) + return parameters + } + } +} diff --git a/app/src/main/java/com/lin/magic/adblock/AbpBlockerManager.kt b/app/src/main/java/com/lin/magic/adblock/AbpBlockerManager.kt new file mode 100644 index 00000000..491d45e9 --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/AbpBlockerManager.kt @@ -0,0 +1,475 @@ +package com.lin.magic.adblock + +import com.lin.magic.R +import com.lin.magic.adblock.AbpBlocker.Companion.addHeader +import com.lin.magic.adblock.AbpBlocker.Companion.removeHeader +import com.lin.magic.constant.FILE +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.isAppScheme +import com.lin.magic.utils.isSpecialUrl +import android.app.Application +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.MimeTypeMap +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import androidx.collection.LruCache +import androidx.core.net.toUri +import androidx.core.util.PatternsCompat +import jp.hazuki.yuzubrowser.adblock.EmptyInputStream +import jp.hazuki.yuzubrowser.adblock.core.AbpLoader +import jp.hazuki.yuzubrowser.adblock.core.ContentRequest +import jp.hazuki.yuzubrowser.adblock.core.FilterContainer +import jp.hazuki.yuzubrowser.adblock.filter.abp.* +import jp.hazuki.yuzubrowser.adblock.filter.unified.* +import jp.hazuki.yuzubrowser.adblock.filter.unified.getFilterDir +import jp.hazuki.yuzubrowser.adblock.filter.unified.io.FilterReader +import jp.hazuki.yuzubrowser.adblock.filter.unified.io.FilterWriter +import jp.hazuki.yuzubrowser.adblock.getContentType +import jp.hazuki.yuzubrowser.adblock.repository.abp.AbpDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import okhttp3.* +import okhttp3.Headers.Companion.toHeaders +import okhttp3.internal.publicsuffix.PublicSuffix +import okhttp3.internal.toHeaderList +import timber.log.Timber +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.charset.StandardCharsets +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AbpBlockerManager @Inject constructor( + private val application: Application, + abpListUpdater: AbpListUpdater, + abpUserRules: AbpUserRules, + val userPreferences: UserPreferences, +) : AdBlocker { + + // use a map of filterContainers instead of several separate containers + private val filterContainers = blockerPrefixes.associateWith { FilterContainer() } + + // store whether lists are loaded (and delay any request until loading is done) + private var listsLoaded = false + + private val okHttpClient by lazy { OkHttpClient() + .newBuilder() + .cookieJar(WebkitCookieManager(CookieManager.getInstance())) + .build() } // we only need it for some filters, so don't create if not necessary + + private val thirdPartyCache = ThirdPartyLruCache(100) + + private val blocker = AbpBlocker(abpUserRules, filterContainers) + + private val cacheDir by lazy { FILE + application.cacheDir.absolutePath } + /* // element hiding + // doesn't work, but maybe it's crucial to inject the js at the right point + // tried onPageFinished, might be too late (try to implement onDomFinished from yuzu?) + private var elementBlocker: CosmeticFiltering? = null + var elementHide = userPreferences.elementHide + */ + + init { + // hilt always loads blocker, even if not used + // thus we load the lists only if blocker is actually enabled + if (userPreferences.adBlockEnabled) + GlobalScope.launch(Dispatchers.Default) { + loadLists() + + // update all enabled entities/blocklists + // may take a while depending on how many lists need update, and on internet connection + if (abpListUpdater.updateAll(false)) { // returns true if anything was updated + removeJointLists() + loadLists() // update again if files have changed + } + } + } + + fun removeJointLists() { + val filterDir = application.applicationContext.getFilterDir() + blockerPrefixes.forEach { File(filterDir, it).delete() } + } + + // load lists + // and create files containing filters from all enabled entities (without duplicates) + fun loadLists() { + val filterDir = application.applicationContext.getFilterDir() + + // call loadFile for all prefixes and be done if all return true + // asSequence() should not load all lists and then check, but fail faster if there is a problem + if (blockerPrefixes.asSequence().map { loadFileToContainer(File(filterDir, it), it) }.all { it }) { + listsLoaded = true + return + } + // loading failed or joint lists don't exist: load the normal way and create joint lists + + val entities = AbpDao(application.applicationContext).getAll() + val abpLoader = AbpLoader(filterDir, entities) + + val filters = blockerPrefixes.associateWith { prefix -> + abpLoader.loadAll(prefix).toSet().sanitize(abpLoader.loadAll(ABP_PREFIX_BADFILTER + prefix).toSet()) + } + + blockerPrefixes.forEach { prefix -> + filterContainers[prefix]!!.clear() // clear container, or disabled filter lists will still be active + filterContainers[prefix]!!.also { filters[prefix]!!.forEach(it::addWithTag) } + } + listsLoaded = true + + // create joint files + // tags will be created again, this is unnecessary, but fast enough to not care about it very much + blockerPrefixes.forEach { prefix -> + writeFile(prefix, filters[prefix]!!) + } + + /*if (elementHide) { + val disableCosmetic = FilterContainer().also { abpLoader.loadAll(ABP_PREFIX_DISABLE_ELEMENT_PAGE).forEach(it::plusAssign) } + val elementFilter = ElementContainer().also { abpLoader.loadAllElementFilter().forEach(it::plusAssign) } + elementBlocker = CosmeticFiltering(disableCosmetic, elementFilter) + }*/ + } + + private fun loadFileToContainer(file: File, prefix: String): Boolean { + if (file.exists()) { + try { + file.inputStream().buffered().use { ins -> + val reader = FilterReader(ins) + if (!reader.checkHeader()) + return false + if (isModify(prefix)) + reader.readAllModifyFilters().forEach(filterContainers[prefix]!!::addWithTag) + else + reader.readAll().forEach(filterContainers[prefix]!!::addWithTag) + // check 2nd "header" at end of the file, to avoid accepting partially written file + return reader.checkHeader() + } + } catch (e: IOException) { // nothing to do, returning false anyway + } + } + return false + } + + private fun writeFile(prefix: String, filters: Collection>?) { + if (filters == null) return // better throw error, should not happen + val file = File(application.applicationContext.getFilterDir(), prefix) + val writer = FilterWriter() + file.outputStream().buffered().use { + if (isModify(prefix)) + // use !! to get error if filter.modify is null + writer.writeModifyFiltersWithTag(it, filters.toList()) + else + writer.writeWithTag(it, filters.toList()) + it.close() + } + } + + // returns null if not blocked, else some WebResourceResponse + override fun shouldBlock(request: WebResourceRequest, pageUrl: String): WebResourceResponse? { + // always allow special URLs, app scheme and cache dir (used for favicons) + request.url.toString().let { + if (it.isSpecialUrl() || it.isAppScheme() || it.startsWith(cacheDir)) + return null + } + + // create contentRequest + // pageUrl can be "" (when opening something in a new tab, or manually entering a URL) + // in this case everything gets blocked because of the pattern "|https://" + // this is blocked for some specific page domains + // and if pageUrl.host == null, domain check return true (in UnifiedFilter.kt) + // same for is3rdParty + // if switching pages (via link or pressing back), pageUrl is still the old url, messing up 3rd party checks + // -> fix both by setting pageUrl to requestUrl if request.isForMainFrame + // is there any way a request for main frame can be a 3rd party request? then a different fix would be required + val contentRequest = request.getContentRequest( + if (request.isForMainFrame || pageUrl.isBlank()) request.url else pageUrl.toUri() + ) + + // wait until blocklists are loaded + // web request stuff does not run on main thread, so thread.sleep should be ok + while (!listsLoaded) { + Thread.sleep(50) + } + + // blocker shouldBlock + val response = blocker.shouldBlock(contentRequest) ?: return null + + when (response) { + is BlockResponse -> { + return if (request.isForMainFrame) + createMainFrameDummy(request.url, response.blockList, response.pattern) + else when(contentRequest.type) { + ContentRequest.TYPE_OTHER -> BlockResourceResponse(RES_EMPTY) + ContentRequest.TYPE_IMAGE -> BlockResourceResponse(RES_1X1) + ContentRequest.TYPE_SUB_DOCUMENT -> BlockResourceResponse(RES_NOOP_HTML) + ContentRequest.TYPE_SCRIPT -> BlockResourceResponse(RES_NOOP_JS) + ContentRequest.TYPE_MEDIA -> BlockResourceResponse(RES_NOOP_MP3) + else -> BlockResourceResponse(RES_EMPTY) + }.toWebResourceResponse() + } + is BlockResourceResponse -> return response.toWebResourceResponse() + is ModifyResponse -> { + if ( + // okhttp accepts only ws, wss, http, https, can't build a request otherwise + request.url.scheme !in okHttpAcceptedSchemes + // modify filter implementation still has some problems, allow users to disable + || userPreferences.modifyFilters == 0 + // for some reason, requests done via okhttp on main frame may cause problems + // occurs for example on heise.de + // allow users to disable on main frame only + || (request.isForMainFrame && userPreferences.modifyFilters == 1) + // webresourcerequest does not contain request body, but these request types must or can have a body + || request.method == "POST" || request.method == "PUT" || request.method == "PATCH" || request.method == "DELETE" + // TODO: update in a way that a body can be provided, try https://github.com/KonstantinSchubert/request_data_webviewclient + ) + return null + try { + val newRequest = Request.Builder() + .url(response.url) + .method(response.requestMethod, null) // use same method, no body to copy from WebResourceRequest + .headers(response.requestHeaders.toHeaders()) + .build() + val webResponse = okHttpClient.newCall(newRequest).execute() + if (response.addResponseHeaders == null && response.removeResponseHeaders == null) + return webResponse.toWebResourceResponse(null) + val headers = webResponse.headers.toMap() + response.addResponseHeaders?.forEach { headers.addHeader(it) } + response.removeResponseHeaders?.forEach { headers.removeHeader(it) } + return webResponse.toWebResourceResponse(headers) + } catch (e: Exception) { + // connection problems + // problems when building okhttp Request + // problems when creating WebResourceResponse + Timber.e(e,"error while doing modified request for ${response.url}: ") + return null // allow webview to try again, even though this should be modified... + } + } + else -> Timber.d("unknown blocker response type: ${response.javaClass}") + } + return null + } + + // moved from jp.hazuki.yuzubrowser.adblock/AdBlock.kt to allow modified 3rd party detection + private fun WebResourceRequest.getContentRequest(pageUri: Uri): ContentRequest { + val pageHost = pageUri.host?.lowercase() + return ContentRequest(url, pageHost, getContentType(pageUri), is3rdParty(url, pageHost), requestHeaders, method) + } + + // initially based on jp.hazuki.yuzubrowser.adblock/AdBlock.kt + private fun is3rdParty(url: Uri, pageHost: String?): Int { + val hostName = url.host?.lowercase() ?: return THIRD_PARTY + if (pageHost == null) return THIRD_PARTY + + if (hostName == pageHost) return STRICT_FIRST_PARTY + + return if (thirdPartyCache["$hostName/$pageHost"]!!) // thirdPartyCache.Create can't return null! + THIRD_PARTY + else + FIRST_PARTY + } + + // builder part from yuzu: jp.hazuki.yuzubrowser.adblock/AdBlockController.kt + private fun createMainFrameDummy(uri: Uri, blockList: String, pattern: String): WebResourceResponse { + val reasonString = when (blockList) { + USER_BLOCKED -> application.resources.getString(R.string.page_blocked_list_user, pattern) + ABP_PREFIX_IMPORTANT -> application.resources.getString(R.string.page_blocked_list_malware, pattern) + ABP_PREFIX_DENY -> application.resources.getString(R.string.page_blocked_list_ad, pattern) // should only be ABP_PREFIX_DENY + else -> { + Timber.d("unexpected blocklist when creating main frame dummy: $blockList") + application.resources.getString(R.string.page_blocked_list_ad, pattern) + } + } + + val builder = StringBuilder( + "" + + "" + + "" + ) + .append(application.resources.getText(R.string.request_blocked)) + .append("

") + .append(application.resources.getText(R.string.page_blocked)) + .append("

")
+            .append(uri)
+            .append("

") + .append(application.resources.getText(R.string.page_blocked_reason)) + .append("

")
+            .append(reasonString)
+            .append("
") + + return getNoCacheResponse("text/html", builder) + } + + private fun Response.toWebResourceResponse(modifiedHeaders: Map?): WebResourceResponse { + // content-type usually has format "text/html, charset=utf-8" or "text/html" + val contentType = (header("content-type") ?: "text/plain").split(';') + // WebResourceResponse doesn't accept codes in this range, but okhttp response sometimes to have them + val responseCode = if (code < 300 || code > 399) code else 200 + return WebResourceResponse( + contentType.first(), + if (contentType.size > 1 && contentType[1].lowercase().startsWith("charset=")) + contentType[1].substringAfter('=') + else null, + responseCode, + message.let { if (it.isEmpty()) "OK" else it }, // must not be empty, but sometimes okhttp response has empty message + modifiedHeaders ?: headers.toMap(), + body?.byteStream() ?: EmptyInputStream() + ) + } + + private fun Headers.toMap(): MutableMap { + val map = mutableMapOf() + toHeaderList().forEach { + map[it.name.utf8()] = it.value.utf8() + } + return map + } + + // TODO: load from file every time? is there some caching in the background? cache files using by lazy? + private fun BlockResourceResponse.toWebResourceResponse(): WebResourceResponse { + val mimeType = getMimeType(filename) + return WebResourceResponse( + mimeType, + if (mimeType.startsWith("application") || mimeType.startsWith("text")) + "utf-8" + else null, + application.assets.open("blocker_resources/$filename") + ) + } + + /* + // element hiding + override fun loadScript(uri: Uri): String? { + val cosmetic = elementBlocker ?: return null + return cosmetic.loadScript(uri) + return null + } + */ + + companion object { + val blockerPrefixes = listOf( + ABP_PREFIX_ALLOW, + ABP_PREFIX_DENY, + ABP_PREFIX_MODIFY, + ABP_PREFIX_MODIFY_EXCEPTION, + ABP_PREFIX_IMPORTANT, + ABP_PREFIX_IMPORTANT_ALLOW, + ABP_PREFIX_REDIRECT, + ABP_PREFIX_REDIRECT_EXCEPTION, + ) + val badfilterPrefixes = blockerPrefixes.map { ABP_PREFIX_BADFILTER + it} + + fun isModify(prefix: String) = prefix in listOf(ABP_PREFIX_MODIFY, ABP_PREFIX_MODIFY_EXCEPTION, ABP_PREFIX_REDIRECT, ABP_PREFIX_REDIRECT_EXCEPTION) + + private val okHttpAcceptedSchemes = listOf("https", "http", "ws", "wss") + + // from jp.hazuki.yuzubrowser.core.utility.utils/FileUtils.kt + const val MIME_TYPE_UNKNOWN = "application/octet-stream" + + fun getMimeType(fileName: String): String { + val lastDot = fileName.lastIndexOf('.') + if (lastDot >= 0) { + val extension = fileName.substring(lastDot + 1).lowercase() + // strip potentially leftover parameters and fragment + .substringBefore('?').substringBefore('#') + return getMimeTypeFromExtension(extension) + } + return MIME_TYPE_UNKNOWN + } + + // from jp.hazuki.yuzubrowser.core.utility.utils/FileUtils.kt + fun getMimeTypeFromExtension(extension: String): String { + return when (extension) { + "js" -> "application/javascript" + "mhtml", "mht" -> "multipart/related" + "json" -> "application/json" + else -> { + val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (type.isNullOrEmpty()) { + MIME_TYPE_UNKNOWN + } else { + type + } + } + } + } + + // from jp.hazuki.yuzubrowser.core.utility.extensions/HtmlExtensions.kt + fun getNoCacheResponse(mimeType: String, sequence: CharSequence): WebResourceResponse { + return getNoCacheResponse( + mimeType, ByteArrayInputStream( + sequence.toString().toByteArray( + StandardCharsets.UTF_8 + ) + ) + ) + } + + // from jp.hazuki.yuzubrowser.core.utility.extensions/HtmlExtensions.kt + private fun getNoCacheResponse(mimeType: String, stream: InputStream): WebResourceResponse { + val response = WebResourceResponse(mimeType, "UTF-8", stream) + response.responseHeaders = + HashMap().apply { put("Cache-Control", "no-cache") } + return response + } + + fun Collection>.sanitize(badFilters: Collection>): List> { + val badFilterFilters = badFilters.map { it.second } + + // TODO: badfilter should also work with wildcard domain matching as described on https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#badfilter-modifier + // resp. https://github.com/gorhill/uBlock/wiki/Static-filter-syntax#badfilter + // -> if badfilter matches filter only ignoring domains -> remove matching domains from the filter, also match wildcard + val filters = filterNot { + badFilterFilters.contains(it.second) + } + + // TODO: remove filters contained in others + // e.g. ||example.com^ and ||ads.example.com^, or ||example.com^ and ||example.com^$third-party + // use tags for the first, and hashCode without 3rd party for the second? + + // TODO: combine filters that are the same except for domains or content type + // but this may be slow and not worth much work... + + // maybe limit checks to certain types where such things occur more often? + + return filters + } + + } + + private class ThirdPartyLruCache(size: Int): LruCache(size) { + override fun create(key: String): Boolean { + return key.split('/').let { is3rdParty(it[0], it[1])} + } + + private fun is3rdParty(hostName: String, pageHost: String): Boolean { + val ipPattern = PatternsCompat.IP_ADDRESS + if (ipPattern.matcher(hostName).matches() || ipPattern.matcher(pageHost).matches()) + return true + val db = PublicSuffix.get() + return db.getEffectiveTldPlusOne(hostName) != db.getEffectiveTldPlusOne(pageHost) + } + } +} + +/** + * + */ +private class WebkitCookieManager (private val cookieManager: CookieManager) : CookieJar { + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + cookies.forEach { cookie -> + cookieManager.setCookie(url.toString(), cookie.toString()) + } + } + + override fun loadForRequest(url: HttpUrl): List = + when (val cookies = cookieManager.getCookie(url.toString())) { + null -> emptyList() + else -> cookies.split("; ").mapNotNull { Cookie.parse(url, it) } + } +} diff --git a/app/src/main/java/com/lin/magic/adblock/AbpListUpdater.kt b/app/src/main/java/com/lin/magic/adblock/AbpListUpdater.kt new file mode 100644 index 00000000..1a54cdb5 --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/AbpListUpdater.kt @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2017-2021 Hazuki + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.lin.magic.adblock + +import com.lin.magic.R +import com.lin.magic.adblock.AbpBlockerManager.Companion.blockerPrefixes +import com.lin.magic.adblock.AbpBlockerManager.Companion.isModify +import com.lin.magic.adblock.parser.HostsFileParser +import com.lin.magic.extensions.toast +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.settings.preferences.userAgent +import android.app.Application +import android.content.Context +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Handler +import android.os.Looper +import jp.hazuki.yuzubrowser.adblock.core.ContentRequest +import jp.hazuki.yuzubrowser.adblock.filter.abp.* +import jp.hazuki.yuzubrowser.adblock.filter.unified.FILTER_DIR +import jp.hazuki.yuzubrowser.adblock.filter.unified.StartEndFilter +import jp.hazuki.yuzubrowser.adblock.filter.unified.UnifiedFilter +import jp.hazuki.yuzubrowser.adblock.filter.unified.element.ElementFilter +import jp.hazuki.yuzubrowser.adblock.filter.unified.io.ElementWriter +import jp.hazuki.yuzubrowser.adblock.filter.unified.io.FilterWriter +import jp.hazuki.yuzubrowser.adblock.repository.abp.AbpDao +import jp.hazuki.yuzubrowser.adblock.repository.abp.AbpEntity +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.max + +// this is a slightly modified part of jp.hazuki.yuzubrowser.adblock.service/AbpUpdateService.kt +class AbpListUpdater @Inject constructor(val context: Context) { + + val okHttpClient by lazy { + OkHttpClient().newBuilder() + .callTimeout(5, TimeUnit.MINUTES) + .connectTimeout(5, TimeUnit.MINUTES) + .readTimeout(5, TimeUnit.MINUTES) + .writeTimeout(5, TimeUnit.MINUTES) + .build() + } + + @Inject internal lateinit var userPreferences: UserPreferences + + val abpDao = AbpDao(context) + + fun updateAll(forceUpdate: Boolean): Boolean { + var result = false + runBlocking { + + var nextUpdateTime = Long.MAX_VALUE + val now = System.currentTimeMillis() + abpDao.getAll().forEach { + if (forceUpdate || (needsUpdate(it) && it.enabled)) { + val localResult = updateInternal(it, forceUpdate) + if (localResult && it.expires > 0) { + val nextTime = it.expires * AN_HOUR + now + if (nextTime < nextUpdateTime) nextUpdateTime = nextTime + } + result = result or localResult + } + } + } + return result + } + + fun removeFiles(entity: AbpEntity) { + val dir = getFilterDir() + val writer = FilterWriter() + (blockerPrefixes + ABP_PREFIX_DISABLE_ELEMENT_PAGE).forEach { + writer.write(dir.getFilterFile(it, entity), listOf()) + } + + val elementWriter = ElementWriter() + elementWriter.write(dir.getFilterFile(ABP_PREFIX_ELEMENT, entity), listOf()) + } + + fun updateAbpEntity(entity: AbpEntity, forceUpdate: Boolean) = runBlocking { + updateInternal(entity, forceUpdate) + } + + private suspend fun updateInternal(entity: AbpEntity, forceUpdate: Boolean = false): Boolean { + return when { + entity.url.startsWith("http") -> updateHttp(entity, forceUpdate) + entity.url.startsWith("file") -> updateFile(entity) + else -> false + } + } + + private fun getFilterDir() = context.getDir(FILTER_DIR, Context.MODE_PRIVATE) + + private suspend fun updateHttp(entity: AbpEntity, forceUpdate: Boolean): Boolean { + // don't update if auto-update settings don't allow + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (!forceUpdate + && ((userPreferences.blockListAutoUpdate == AbpUpdateMode.WIFI_ONLY && cm.isActiveNetworkMetered) + || userPreferences.blockListAutoUpdate == AbpUpdateMode.NONE)) + return false + + val request = try { + Request.Builder() + .url(entity.url) + .header("User-Agent", userPreferences.userAgent(context.applicationContext as Application)) + .get() + } catch (e: IllegalArgumentException) { + return false + } + + if (!forceUpdate) { + entity.lastModified?.let { + val dir = getFilterDir() + if ((blockerPrefixes + ABP_PREFIX_DISABLE_ELEMENT_PAGE + ABP_PREFIX_ELEMENT).map { prefix -> + dir.getFilterFile(prefix, entity).exists() }.any() + ) + request.addHeader("If-Modified-Since", it) + } + } + + val call = okHttpClient.newCall(request.build()) + try { + val response = call.execute() + + if (response.code == 304) { + entity.lastLocalUpdate = System.currentTimeMillis() + abpDao.update(entity) + return false + } + if (!response.isSuccessful) { + Handler(Looper.getMainLooper()).post { + context.toast(context.getString(R.string.blocklist_update_error_code, entity.title, response.code.toString())) + } + return false + } + response.body?.run { + val charset = contentType()?.charset() ?: Charsets.UTF_8 + source().inputStream().bufferedReader(charset).use { reader -> + if (decode(reader, charset, entity)) { + entity.lastLocalUpdate = System.currentTimeMillis() + entity.lastModified = response.header("Last-Modified") + abpDao.update(entity) + return true + } + } + } + } catch (e: IOException) { + e.printStackTrace() + Handler(Looper.getMainLooper()).post { + context.toast(context.getString(R.string.blocklist_update_error, entity.title)) + } + } + return false + } + + private suspend fun updateFile(entity: AbpEntity): Boolean { + val path = Uri.parse(entity.url).path ?: return false + val file = File(path) + if (file.lastModified() < entity.lastLocalUpdate) return false + + try { + file.inputStream().bufferedReader().use { reader -> + return decode(reader, Charsets.UTF_8, entity) + } + } catch (e: IOException) { + e.printStackTrace() + } + return false + } + + private suspend fun decode(reader: BufferedReader, charset: Charset, entity: AbpEntity): Boolean { + val decoder = AbpFilterDecoder() + val dir = getFilterDir() + val writer = FilterWriter() + + if (!decoder.checkHeader(reader, charset)) { + // no adblock plus format, try hosts reader + val parser = HostsFileParser() + // use StartEndFilter, which also matches subdomains + // not strictly according to hosts rules, but uBlock does the same (and it makes sense) + val hostsList = parser.parseInput(reader).map { + StartEndFilter(it.name, ContentRequest.TYPE_ALL, false, null, -1) + } + if (hostsList.isEmpty()) + return false + entity.lastLocalUpdate = System.currentTimeMillis() + writer.write(dir.getFilterFile(ABP_PREFIX_DENY, entity), hostsList) + abpDao.update(entity) + + return true + } + + val set = decoder.decode(reader, entity.url) + + val info = set.filterInfo + if (entity.title == null) // only update title if there is none + entity.title = info.title + entity.expires = info.expires ?: -1 + entity.homePage = info.homePage + entity.version = info.version + entity.lastUpdate = info.lastUpdate + info.redirectUrl?.let { entity.url = it } + entity.lastLocalUpdate = System.currentTimeMillis() + blockerPrefixes.forEach { + if (isModify(it)) + writer.writeModifyFilters(dir.getFilterFile(it, entity), set.filters[it]) + else + writer.write(dir.getFilterFile(it, entity), set.filters[it]) + } + writer.write(dir.getFilterFile(ABP_PREFIX_DISABLE_ELEMENT_PAGE,entity), set.elementDisableFilter) + + val elementWriter = ElementWriter() + elementWriter.write(dir.getFilterFile(ABP_PREFIX_ELEMENT, entity), set.elementList) + + abpDao.update(entity) + return true + } + + private fun FilterWriter.write(file: File, list: List) { + if (list.isNotEmpty()) { + try { + file.outputStream().buffered().use { + write(it, list) + } + } catch (e: IOException) { +// ErrorReport.printAndWriteLog(e) + } + } else { + if (file.exists()) file.delete() + } + } + + private fun FilterWriter.writeModifyFilters(file: File, list: List) { + if (list.isNotEmpty()) { + try { + file.outputStream().buffered().use { + writeModifyFilters(it, list) + } + } catch (e: IOException) { +// ErrorReport.printAndWriteLog(e) + } + } else { + if (file.exists()) file.delete() + } + } + + private fun ElementWriter.write(file: File, list: List) { + if (list.isNotEmpty()) { + try { + file.outputStream().buffered().use { + write(it, list) + } + } catch (e: IOException) { +// ErrorReport.printAndWriteLog(e) + } + } else { + if (file.exists()) file.delete() + } + } + + fun needsUpdate(entity: AbpEntity): Boolean { + val now = System.currentTimeMillis() + if (now - entity.lastLocalUpdate >= max(entity.expires * AN_HOUR, A_DAY * userPreferences.blockListAutoUpdateFrequency)) { + return true + } + return false + } + + companion object { + private const val AN_HOUR = 60 * 60 * 1000L + private const val A_DAY = 24 * AN_HOUR + + } + +} diff --git a/app/src/main/java/com/lin/magic/adblock/AbpUpdateMode.kt b/app/src/main/java/com/lin/magic/adblock/AbpUpdateMode.kt new file mode 100644 index 00000000..075dee54 --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/AbpUpdateMode.kt @@ -0,0 +1,13 @@ +package com.lin.magic.adblock + +import com.lin.magic.settings.preferences.IntEnum + +/** + * An enum representing the browser's available rendering modes. + */ +enum class AbpUpdateMode(override val value: Int) : + IntEnum { + ALWAYS(0), + NONE(1), + WIFI_ONLY(2) +} diff --git a/app/src/main/java/com/lin/magic/adblock/AbpUserRules.kt b/app/src/main/java/com/lin/magic/adblock/AbpUserRules.kt new file mode 100644 index 00000000..5aa498bd --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/AbpUserRules.kt @@ -0,0 +1,136 @@ +package com.lin.magic.adblock + +import com.lin.magic.database.adblock.UserRulesRepository +import android.net.Uri +import jp.hazuki.yuzubrowser.adblock.core.ContentRequest +import jp.hazuki.yuzubrowser.adblock.filter.unified.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/* + user rules: + implements what is called 'dynamic filtering' in ubo: https://github.com/gorhill/uBlock/wiki/Dynamic-filtering:-quick-guide + uses only 2 types of filters and a special filter container working with pageUrl.host as tag (empty for global rules) + each rule is a single filter! this is important for easy display of existing filter rules + -> TODO: test how much slower it is compared to optimized filters + e.g. global block of example.net and example.com could be one filter with both domains in the domainMap + but when adding/removing rules, it would be necessary to remove the filter, and add a new one with modified domainMap + definitely possible, but is it worth the work? + also content types could be joined + first tests: no problem if all filters use different tags + -> try using a lot of global filters (empty tag), each with some domain + this is not yet implemented, but will be used for uBo style dynamic filtering + TODO: improve loading speed + slow load speed is why yuzu uses storage in files instead of DB (current db is 10 times slower!) + loading (in background) takes some 100 ms for few entries, 1.7 s for 2400 entries (S4 mini) + ca half time if not loading at the same time as ad block lists + -> it's not horrible, but still needs to be improved + first step: check which part is slow -> loading from db takes twice as long as creating the filters, adding to UserFilterContainer is (currently) negligible + */ + +@Singleton +class AbpUserRules @Inject constructor( + private val userRulesRepository: UserRulesRepository +){ + + private val userRules by lazy { UserFilterContainer().also{ userRulesRepository.getAllRules().forEach(it::add) } } +// private lateinit var userRules: UserFilterContainer +// private val userRules = UserFilterContainer() + + init { + // TODO: maybe move to background? + // try: + // by lazy: may needlessly delay first request -> by 1.7 s for 2400 entries on S4 mini + // but: adblock list is loading parallel, so time loss not that bad + // load blocking: may block for too long -> how long? + // load in background -> need to check every time whether it's already loaded +// loadUserLists() + } + +/* private fun loadUserLists() { + // on S4 mine: takes 150 ms for the first time, then 6 ms for empty db + val ur = userRulesRepository.getAllRules() + // careful: the following line crashes (my) android studio: +// userRules = UserFilterContainer().also { ur.forEach(it::add) } } + // why? it's the same way used for 'normal' filter containers + +// userRules = UserFilterContainer() + ur.forEach { userRules.add(it) } + } +*/ + + // true: block + // false: allow + // null: nothing (relevant to supersede more general block/allow rules) + fun getResponse(contentRequest: ContentRequest): Boolean? { + return userRules.get(contentRequest)?.response + } + + private fun addUserRule(filter: UnifiedFilterResponse) { + userRules.add(filter) + GlobalScope.launch(Dispatchers.IO) { userRulesRepository.addRules(listOf(filter)) } + } + + private fun removeUserRule(filter: UnifiedFilterResponse) { + userRules.remove(filter) + GlobalScope.launch(Dispatchers.IO) { userRulesRepository.removeRule(filter) } + } + +/* + some examples + entire page: , "", ContentRequest.TYPE_ALL, false + everything from youtube.com: "", "youtube.com", ContentRequest.TYPE_ALL, false + everything 3rd party from youtube.com: "", "youtube.com", ContentRequest.TYPE_ALL, true + all 3rd party frames: "", "", ContentRequest.TYPE_SUB_DOCUMENT, true //TODO: SHOULD be sub_document, but not checked + + find content types in ContentRequest, and how to get it from request in AdBlock -> WebResourceRequest.getContentType + */ + + // domains as returned by url.host -> should be valid, and not contains htto(s) + private fun createUserFilter(pageDomain: String, requestDomain: String, contentType: Int, thirdParty: Boolean): UnifiedFilter { + // 'domains' contains (usually 3rd party) domains, but can also be same as requestDomain (or subdomain of requestDomain) + // include is always set to true (filter valid only on this domain, and any subdomain if there is no more specific rule) + val domains = if (pageDomain.isNotEmpty()) + SingleDomainMap(true, pageDomain) + else null + + // thirdParty true means filter only applied to 3rd party content, translates to 1 in the filter + // 0 would be only first party, -1 is for both + // maybe implement 0 as well, but I think it's not used in uBo (why would i want to block 1st, but not 3rd party stuff?) + val thirdPartyInt = if (thirdParty) 1 else -1 + + // HostFilter for specific request domain, ContainsFilter with empty pattern otherwise + return if (requestDomain.isEmpty()) + ContainsFilter(requestDomain, contentType, true, domains, thirdPartyInt) + else + HostFilter(requestDomain, contentType, domains, thirdPartyInt) + } + + fun addUserRule(pageDomain: String, requestDomain: String, contentType: Int, thirdParty: Boolean, response: Boolean?) { + addUserRule(UnifiedFilterResponse(createUserFilter(pageDomain, requestDomain, contentType, thirdParty), response)) + } + + fun removeUserRule(pageDomain: String, requestDomain: String, contentType: Int, thirdParty: Boolean, response: Boolean?) { + removeUserRule(UnifiedFilterResponse(createUserFilter(pageDomain, requestDomain, contentType, thirdParty), response)) + } + + fun isAllowed(pageUrl: Uri): Boolean { + // TODO: checking by using a fake request might be "slower than necessary"? but sure is faster a than DB query + // anyway, this needs to be changed once there can be more rules for a page + return userRules.get(ContentRequest(pageUrl, pageUrl.host, ContentRequest.TYPE_ALL, FIRST_PARTY, tags = listOf("")))?.response == false + } + + fun allowPage(pageUrl: Uri, add: Boolean) { + val domain = pageUrl.host ?: return + if (add) + addUserRule(domain, "", ContentRequest.TYPE_ALL, thirdParty = false, response = false) + else + removeUserRule(domain, "", ContentRequest.TYPE_ALL, thirdParty = false, response = false) + } + +} + +const val USER_BLOCKED = "user" diff --git a/app/src/main/java/com/lin/magic/adblock/AdBlocker.kt b/app/src/main/java/com/lin/magic/adblock/AdBlocker.kt new file mode 100644 index 00000000..af3eda88 --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/AdBlocker.kt @@ -0,0 +1,25 @@ +package com.lin.magic.adblock + +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse + +/** + * The ad blocking interface. + */ +interface AdBlocker { + + /** + * a method that determines if the given URL is an ad or not. It performs a search of the URL's + * domain on the blocked domain hash set. + * + * @param url the URL to check for being an ad. + * @return true if it is an ad, false if it is not an ad. + */ + // not used in the new blocker + //fun isAd(url: String): Boolean + + // this is for element hiding only, which is currently not working and disabled + //fun loadScript(uri: Uri): String? + + fun shouldBlock(request: WebResourceRequest, pageUrl: String): WebResourceResponse? +} diff --git a/app/src/main/java/com/lin/magic/adblock/BlockerResponse.kt b/app/src/main/java/com/lin/magic/adblock/BlockerResponse.kt new file mode 100644 index 00000000..56b159d4 --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/BlockerResponse.kt @@ -0,0 +1,85 @@ +package com.lin.magic.adblock + +abstract class BlockerResponse + +data class BlockResponse(val blockList: String, val pattern: String): BlockerResponse() + +data class ModifyResponse(val url: String, val requestMethod: String, val requestHeaders: Map, val addResponseHeaders: Map?, val removeResponseHeaders: Collection?): BlockerResponse() + +class BlockResourceResponse(resource: String): BlockerResponse() { + val filename = when (resource) { + RES_EMPTY -> RES_EMPTY // first because used for normal block and thus frequent + RES_1X1, "1x1-transparent.gif" -> RES_1X1 + RES_2X2, "2x2-transparent.png" -> RES_2X2 + RES_3X2, "3x2-transparent.png" -> RES_3X2 + RES_32X32, "32x32-transparent.png" -> RES_32X32 + RES_ADDTHIS, "addthis.com/addthis_widget.js" -> RES_ADDTHIS + RES_AMAZON_ADS, "amazon-adsystem.com/aax2/amzn_ads.js" -> RES_AMAZON_ADS + RES_CHARTBEAT, "static.chartbeat.com/chartbeat.js" -> RES_CHARTBEAT + RES_CLICK2LOAD, "aliasURL", "url" -> RES_CLICK2LOAD + RES_DOUBLECLICK, "doubleclick.net/instream/ad_status.js" -> RES_DOUBLECLICK + RES_ANALYTICS, "google-analytics.com/analytics.js", "googletagmanager_gtm.js", "googletagmanager.com/gtm.js" -> RES_ANALYTICS + RES_ANALYTICS_CX, "google-analytics.com/cx/api.js" -> RES_ANALYTICS_CX + RES_ANALYTICS_GA, "google-analytics.com/ga.js" -> RES_ANALYTICS_GA + RES_ANALYTICS_INPAGE, "google-analytics.com/inpage_linkid.js" -> RES_ANALYTICS_INPAGE + RES_ADSBYGOOGLE, "googlesyndication.com/adsbygoogle.js" -> RES_ADSBYGOOGLE + RES_GOOGLETAGSERVICES, "googletagservices.com/gpt.js" -> RES_GOOGLETAGSERVICES + RES_LIGATUS, "ligatus.com/*/angular-tag.js" -> RES_LIGATUS + RES_MONEYBROKER, "d3pkae9owd2lcf.cloudfront.net/mb105.js" -> RES_MONEYBROKER + RES_NOEVAL_SILENT, "silent-noeval.js'" -> RES_NOEVAL_SILENT + RES_NOFAB, "fuckadblock.js-3.2.0" -> RES_NOFAB + RES_NOOP_MP3, "noopmp3-0.1s", "abp-resource:blank-mp3" -> RES_NOOP_MP3 + RES_NOOP_MP4, "noopmp4-1s" -> RES_NOOP_MP4 + RES_NOOP_HTML, "noopframe" -> RES_NOOP_HTML + RES_NOOP_JS, "noopjs", "abp-resource:blank-js" -> RES_NOOP_JS + RES_NOOP_TXT, "nooptext" -> RES_NOOP_TXT + RES_NOOP_VMAP, "noopvmap-1." -> RES_NOOP_VMAP + RES_OUTBRAIN, "widgets.outbrain.com/outbrain.js" -> RES_OUTBRAIN + RES_POPADS, "popads.net.js" -> RES_POPADS + RES_SCORECARD, "scorecardresearch.com/beacon.js" -> RES_SCORECARD + RES_WINDOW_OPEN_DEFUSER, "nowoif.js" -> RES_WINDOW_OPEN_DEFUSER + RES_HD_MAIN, RES_MXPNL, RES_NOEVAL, RES_NOBAB_2, RES_POPADS_DUMMY, RES_FINGERPRINT_2, RES_AMAZON_APSTAG, RES_AMPPROJECT -> resource // no alias -> keep name + + else -> RES_EMPTY // might happen if new block resources are added to uBo, or if there is a type in the list + } + +} + +const val RES_EMPTY = "empty" +const val RES_1X1 = "1x1.gif" +const val RES_2X2 = "2x2.png" +const val RES_3X2 = "3x2.png" +const val RES_32X32 = "32x32.png" +const val RES_ADDTHIS = "addthis_widget.js" +const val RES_AMAZON_ADS = "amazon_ads.js" +const val RES_AMAZON_APSTAG = "amazon_apstag.js" +const val RES_AMPPROJECT = "ampproject_v0.js" +const val RES_CHARTBEAT = "chartbeat.js" +const val RES_CLICK2LOAD = "click2load.html" +const val RES_DOUBLECLICK = "doubleclick_instream_ad_status.js" +const val RES_FINGERPRINT_2 = "fingerprint2.js" +const val RES_ANALYTICS = "google-analytics_analytics.js" +const val RES_ANALYTICS_CX = "google-analytics_cx_api.js" +const val RES_ANALYTICS_GA = "google-analytics_ga.js" +const val RES_ANALYTICS_INPAGE = "google-analytics_inpage_linkid.js" +const val RES_ADSBYGOOGLE = "googlesyndication_adsbygoogle.js" +const val RES_GOOGLETAGSERVICES = "googletagservices_gpt.js" +const val RES_HD_MAIN ="hd-main.js" +const val RES_LIGATUS = "ligatus_angular-tag.js" +const val RES_MXPNL = "mxpnl_mixpanel.js" +const val RES_MONEYBROKER = "monkeybroker.js" +const val RES_NOEVAL = "noeval.js" +const val RES_NOEVAL_SILENT = "noeval-silent.js" +const val RES_NOBAB_2 = "nobab2.js" +const val RES_NOFAB = "nofab.js" +const val RES_NOOP_MP3 = "noop-0.1s.mp3" +const val RES_NOOP_MP4 = "noop-1s.mp4" +const val RES_NOOP_HTML = "noop.html" +const val RES_NOOP_JS = "noop.js" +const val RES_NOOP_TXT = "noop.txt" +const val RES_NOOP_VMAP = "noop-vmap1.0.xml" +const val RES_OUTBRAIN = "outbrain-widget.js" +const val RES_POPADS = "popads.js" +const val RES_POPADS_DUMMY = "popads-dummy.js" +const val RES_SCORECARD = "scorecardresearch_beacon.js" +const val RES_WINDOW_OPEN_DEFUSER = "window.open-defuser.js" diff --git a/app/src/main/java/com/lin/magic/adblock/NoOpAdBlocker.kt b/app/src/main/java/com/lin/magic/adblock/NoOpAdBlocker.kt new file mode 100644 index 00000000..aead1a3f --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/NoOpAdBlocker.kt @@ -0,0 +1,21 @@ +package com.lin.magic.adblock + +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import dagger.Reusable +import javax.inject.Inject + +/** + * A no-op ad blocker implementation. Always returns false for [isAd]. + */ +@Reusable +class NoOpAdBlocker @Inject constructor() : + AdBlocker { + + //override fun isAd(url: String) = false + + // unused element hiding currently disabled + //override fun loadScript(uri: Uri): String? = null + + override fun shouldBlock(request: WebResourceRequest, pageUrl: String): WebResourceResponse? = null +} diff --git a/app/src/main/java/com/lin/magic/adblock/UserFilterContainer.kt b/app/src/main/java/com/lin/magic/adblock/UserFilterContainer.kt new file mode 100644 index 00000000..a37acdbc --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/UserFilterContainer.kt @@ -0,0 +1,102 @@ +package com.lin.magic.adblock + +import jp.hazuki.yuzubrowser.adblock.core.ContentRequest +import jp.hazuki.yuzubrowser.adblock.filter.unified.UnifiedFilter + +// UnifiedFilterResponse looks nicer than Pair +// response: true -> block, false -> allow, null: noop +data class UnifiedFilterResponse(val filter: UnifiedFilter, val response: Boolean?) + +/* + works somewhat similar to FilterContainer, but tailored for uBo-style dynamic filtering + the important part: specific rules should override general rules + so checking allow, then block (as done for ads) can't work here + -> get most specific matching filter and return the associated response + */ +class UserFilterContainer { + private val filters = hashMapOf>() + + private val filterComparator = Comparator { f1, f2 -> + // TODO: order may not be correct, https://github.com/gorhill/uBlock/wiki/Dynamic-filtering:-precedence and uBo settings seem to disagree + // using what the dashboard suggests: first request domain, then 3rd party, then content type, then local + when { + // first: request (sub)domain matches + // filter already matches -> if there is a pattern (=requestDomain) (sub)domain must match + // -> if both domain and subdomain exist, prefer subdomain as it's more specific + // since both must match, the longer one must be more specific (if they're same length, they must be equal) + f1.filter.pattern != f2.filter.pattern -> f1.filter.pattern.length - f2.filter.pattern.length + + // then: third party matches (filter already matches -> prefer specific 3rd party value (1 or 0) over -1) + f1.filter.thirdParty != f2.filter.thirdParty -> f1.filter.thirdParty - f2.filter.thirdParty + + // then: content type more precise (filter already matches -> prefer specific (0 f2.filter.contentType - f1.filter.contentType + + // then: pageDomain matches + // as for request domain: more specific domain wins + f1.filter.domains != f2.filter.domains -> getBetterDomainMatch(f1, f2) + + else -> 0 + } + } + + fun add(filter: UnifiedFilterResponse) { + // no need for explicit tag, it's either pageUrl.host or empty (stored in the domainMap) + // domains can only be SingleDomainMaps with include=true for user rules + val tag = filter.filter.domains?.getKey(0) ?: "" + filters[tag] = filters[tag]?.plus(filter) ?: listOf(filter) + } + + fun remove(filter: UnifiedFilterResponse) { + val tag = filter.filter.domains?.getKey(0) ?: "" + filters[tag]?.mapNotNull { if (it != filter) it else null }?.let { + if (it.isEmpty()) + filters.remove(tag) + else + filters[tag] = it + } + } + + fun get(request: ContentRequest): UnifiedFilterResponse? { + // tags are not really used (only pageDomain and empty) -> ignore tags from contentRequest + + val matchingFilters = (filters[""] ?: listOf()) + (filters[request.pageHost] ?: listOf()) + + if (matchingFilters.isEmpty()) return null + if (allSameResponse(matchingFilters)) return matchingFilters.first() + + // get the highest priority rule according to uBo criteria (see comments inside filterComparator) + // TODO: test whether it does what it should + return matchingFilters.maxOfWith(filterComparator, {it}) + } + + + private fun getBetterDomainMatch(f1: UnifiedFilterResponse, f2: UnifiedFilterResponse): Int { + // pageDomains are not equal, so if one of them is null, the other one is better + val domains1 = f1.filter.domains ?: return -1 + val domains2 = f2.filter.domains ?: return 1 + + // there is only one domain for user rules, and both match but they aren't equal -> the longer one is more specific + return domains1.getKey(0).length - domains2.getKey(0).length + +/* // might be extended later to arrayDomainMaps, as larger domainMaps can increase efficiency (at cost of simple add/remove of filters) + var compare = 0 + for (i in 0..domains1.size) { + if (domains1.getKey(i) == requestDomain) ++compare + } + for (i in 0..domains2.size) { + if (domains2.getKey(i) == requestDomain) --compare + } + return compare*/ + } + + private fun allSameResponse(filters: List): Boolean { + // get first response, go through others, return false if one is different + if (filters.size == 1) return true + val response = filters.first().response + filters.drop(1).forEach { if (it.response != response) return false } + // in the end return true + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/adblock/parser/HostsFileParser.kt b/app/src/main/java/com/lin/magic/adblock/parser/HostsFileParser.kt new file mode 100644 index 00000000..467b8cff --- /dev/null +++ b/app/src/main/java/com/lin/magic/adblock/parser/HostsFileParser.kt @@ -0,0 +1,101 @@ +package com.lin.magic.adblock.parser + +import com.lin.magic.database.adblock.Host +import com.lin.magic.extensions.containsChar +import com.lin.magic.extensions.indexOfChar +import com.lin.magic.extensions.inlineReplace +import com.lin.magic.extensions.inlineReplaceChar +import com.lin.magic.extensions.inlineTrim +import com.lin.magic.extensions.stringEquals +import com.lin.magic.extensions.substringToBuilder +import timber.log.Timber +import java.io.BufferedReader +import java.io.InputStreamReader + +/** + * A single threaded parser for a hosts file. + */ +class HostsFileParser { + + private val lineBuilder = StringBuilder() + + /** + * Parse the lines of the [input] from a hosts file and return the list of [String] domains held + * in that file. + */ + fun parseInput(input: BufferedReader): List { + val time = System.currentTimeMillis() + + val domains = ArrayList(100) + + input.use { inputStreamReader -> + inputStreamReader.forEachLine { + parseLine(it, domains) + } + } + + Timber.d("Parsed ad list in: ${(System.currentTimeMillis() - time)} ms") + + return domains + } + + // not really necessary, can be removed once the "source" part of old ad blocker is removed + fun parseInput(input: InputStreamReader): List { + return parseInput(input.buffered()) + } + + /** + * Parse a [line] from a hosts file and populate the [parsedList] with the extracted hosts. + */ + private fun parseLine(line: String, parsedList: MutableList) { + lineBuilder.setLength(0) + lineBuilder.append(line) + if (lineBuilder.isNotEmpty() && lineBuilder[0] != COMMENT_CHAR) { + lineBuilder.inlineReplace(LOCAL_IP_V4, EMPTY) + lineBuilder.inlineReplace(LOCAL_IP_V4_ALT, EMPTY) + lineBuilder.inlineReplace(LOCAL_IP_V6, EMPTY) + lineBuilder.inlineReplaceChar(TAB_CHAR, SPACE_CHAR) + + val comment = lineBuilder.indexOfChar(COMMENT_CHAR) + if (comment > 0) { + lineBuilder.setLength(comment) + } else if (comment == 0) { + return + } + + lineBuilder.inlineTrim() + + if (lineBuilder.isNotEmpty() && !lineBuilder.stringEquals(LOCALHOST)) { + while (lineBuilder.containsChar(SPACE_CHAR)) { + val space = lineBuilder.indexOfChar(SPACE_CHAR) + val partial = lineBuilder.substringToBuilder(0, space) + partial.inlineTrim() + + val partialLine = partial.toString() + + // Add string to list + if (partialLine.contains('.')) + parsedList.add(Host(partialLine)) + lineBuilder.inlineReplace(partialLine, EMPTY) + lineBuilder.inlineTrim() + } + if (lineBuilder.isNotEmpty() && lineBuilder.containsChar('.')) { + // Add string to list. + parsedList.add(Host(lineBuilder.toString())) + } + } + } + } + + companion object { + + private const val LOCAL_IP_V4 = "127.0.0.1" + private const val LOCAL_IP_V4_ALT = "0.0.0.0" + private const val LOCAL_IP_V6 = "::1" + private const val LOCALHOST = "localhost" + private const val COMMENT_CHAR = '#' + private const val TAB_CHAR = '\t' + private const val SPACE_CHAR = ' ' + private const val EMPTY = "" + } +} diff --git a/app/src/main/java/com/lin/magic/animation/AnimationUtils.kt b/app/src/main/java/com/lin/magic/animation/AnimationUtils.kt new file mode 100644 index 00000000..1e811c37 --- /dev/null +++ b/app/src/main/java/com/lin/magic/animation/AnimationUtils.kt @@ -0,0 +1,46 @@ +package com.lin.magic.animation + +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.Animation +import android.view.animation.Transformation +import android.widget.ImageView +import androidx.annotation.DrawableRes + +/** + * Animation specific helper code. + */ +object AnimationUtils { + + /** + * Creates an animation that rotates an [ImageView] around the Y axis by 180 degrees and changes + * the image resource shown when the view is rotated 90 degrees to the user. + * + * @param imageView the view to rotate. + * @param drawableRes the drawable to set when the view is rotated by 90 degrees. + * @return an animation that will change the image shown by the view. + */ + @JvmStatic + fun createRotationTransitionAnimation( + imageView: ImageView, + @DrawableRes drawableRes: Int + ): Animation = object : Animation() { + + private var setFinalDrawable: Boolean = false + + override fun applyTransformation(interpolatedTime: Float, t: Transformation) = + if (interpolatedTime < 0.5f) { + imageView.rotationY = 90f * interpolatedTime * 2f + } else { + if (!setFinalDrawable) { + setFinalDrawable = true + imageView.setImageResource(drawableRes) + } + imageView.rotationY = -90 + 90f * (interpolatedTime - 0.5f) * 2f + } + + }.apply { + duration = 300 + interpolator = AccelerateDecelerateInterpolator() + } + +} diff --git a/app/src/main/java/com/lin/magic/bookmark/BookmarkImporter.kt b/app/src/main/java/com/lin/magic/bookmark/BookmarkImporter.kt new file mode 100644 index 00000000..e946eaa2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/bookmark/BookmarkImporter.kt @@ -0,0 +1,17 @@ +package com.lin.magic.bookmark + +import com.lin.magic.database.Bookmark +import java.io.InputStream + +/** + * An importer that imports [Bookmark.Entry] from an [InputStream]. Supported formats are details of + * the implementation. + */ +interface BookmarkImporter { + + /** + * Synchronously converts an [InputStream] to a [List] of [Bookmark.Entry]. + */ + fun importBookmarks(inputStream: InputStream): List + +} diff --git a/app/src/main/java/com/lin/magic/bookmark/LegacyBookmarkImporter.kt b/app/src/main/java/com/lin/magic/bookmark/LegacyBookmarkImporter.kt new file mode 100644 index 00000000..0b316950 --- /dev/null +++ b/app/src/main/java/com/lin/magic/bookmark/LegacyBookmarkImporter.kt @@ -0,0 +1,18 @@ +package com.lin.magic.bookmark + +import com.lin.magic.database.Bookmark +import com.lin.magic.database.bookmark.BookmarkExporter +import java.io.InputStream +import javax.inject.Inject + +/** + * A [BookmarkImporter] that imports bookmark files that were produced by [BookmarkExporter]. + */ +class LegacyBookmarkImporter @Inject constructor() : + BookmarkImporter { + + override fun importBookmarks(inputStream: InputStream): List { + return com.lin.magic.database.bookmark.BookmarkExporter.importBookmarksFromFileStream(inputStream) + } + +} diff --git a/app/src/main/java/com/lin/magic/bookmark/NetscapeBookmarkFormatImporter.kt b/app/src/main/java/com/lin/magic/bookmark/NetscapeBookmarkFormatImporter.kt new file mode 100644 index 00000000..989cc5ce --- /dev/null +++ b/app/src/main/java/com/lin/magic/bookmark/NetscapeBookmarkFormatImporter.kt @@ -0,0 +1,80 @@ +package com.lin.magic.bookmark + +import com.lin.magic.constant.UTF8 +import com.lin.magic.database.Bookmark +import com.lin.magic.database.asFolder +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.io.InputStream +import javax.inject.Inject + +/** + * An importer that supports the Netscape Bookmark File Format. + * + * See https://msdn.microsoft.com/en-us/ie/aa753582(v=vs.94) + */ +class NetscapeBookmarkFormatImporter @Inject constructor() : + BookmarkImporter { + + override fun importBookmarks(inputStream: InputStream): List { + val document = Jsoup.parse(inputStream, UTF8, "") + + val rootList = document.body().children().first { it.isTag(LIST_TAG) } + + return rootList.processFolder(ROOT_FOLDER_NAME) + } + + /** + * @return The [List] of [Bookmark.Entry] held by [Element] with the provided [folderName]. + */ + private fun Element.processFolder(folderName: String): List { + return children() + .filter { it.isTag(ITEM_TAG) } + .flatMap { + val immediateChild = it.child(0) + when { + immediateChild.isTag(FOLDER_TAG) -> + immediateChild.nextElementSibling() + .processFolder(computeFolderName(folderName, immediateChild.text())) + immediateChild.isTag(BOOKMARK_TAG) -> + listOf( + Bookmark.Entry( + url = immediateChild.attr(HREF), + title = immediateChild.text(), + position = 0, + folder = folderName.asFolder() + )) + else -> emptyList() + } + } + } + + /** + * @return True if the element's tag name matches the [tagName], case insentitive, false + * otherwise. + */ + private fun Element.isTag(tagName: String): Boolean { + return tagName().equals(tagName, ignoreCase = true) + } + + /** + * @return The [currentFolder] if the [parentFolder] is empty, otherwise prepend the + * [parentFolder] to the [currentFolder] and return that. + */ + private fun computeFolderName(parentFolder: String, currentFolder: String): String = + if (parentFolder.isEmpty()) { + currentFolder + } else { + "$parentFolder/${currentFolder}" + } + + companion object { + const val ITEM_TAG = "DT" + const val LIST_TAG = "DL" + const val BOOKMARK_TAG = "A" + const val FOLDER_TAG = "H3" + const val HREF = "HREF" + const val ROOT_FOLDER_NAME = "" + } + +} diff --git a/app/src/main/java/com/lin/magic/browser/BookmarksView.kt b/app/src/main/java/com/lin/magic/browser/BookmarksView.kt new file mode 100644 index 00000000..8b98586f --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/BookmarksView.kt @@ -0,0 +1,13 @@ +package com.lin.magic.browser + +import com.lin.magic.database.Bookmark + +interface BookmarksView { + + fun navigateBack() + + fun handleUpdatedUrl(url: String) + + fun handleBookmarkDeleted(bookmark: Bookmark) + +} diff --git a/app/src/main/java/com/lin/magic/browser/JavaScriptChoice.kt b/app/src/main/java/com/lin/magic/browser/JavaScriptChoice.kt new file mode 100644 index 00000000..7a2bf177 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/JavaScriptChoice.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2020-2021 Jamal Rothfuchs + * Copyright © 2020-2021 Stéphane Lenclud + * Copyright © 2015 Anthony Restaino + */ + +package com.lin.magic.browser + +import com.lin.magic.settings.preferences.IntEnum + +/** + * The available Block JavaScript choices. + */ +enum class JavaScriptChoice(override val value: Int) : + IntEnum { + NONE(0), + WHITELIST(1), + BLACKLIST(2) +} diff --git a/app/src/main/java/com/lin/magic/browser/MenuMain.kt b/app/src/main/java/com/lin/magic/browser/MenuMain.kt new file mode 100644 index 00000000..67a17cf9 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/MenuMain.kt @@ -0,0 +1,162 @@ +package com.lin.magic.browser + +import com.lin.magic.R +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.databinding.MenuMainBinding +import com.lin.magic.di.HiltEntryPoint +import com.lin.magic.di.configPrefs +import com.lin.magic.settings.preferences.UserPreferences +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.PopupWindow +import androidx.core.view.isVisible +import dagger.hilt.android.EntryPointAccessors + +/** + * That's our browser main navigation menu. + */ +class MenuMain : PopupWindow { + + var iBinding: MenuMainBinding + var iIsIncognito = false + + constructor(layoutInflater: LayoutInflater, aBinding: MenuMainBinding = inflate(layoutInflater)) + : super(aBinding.root, WRAP_CONTENT, WRAP_CONTENT, true) { + + //aBinding.root.context.injector.inject(this) + + iBinding = aBinding + + + // Elevation just need to be high enough not to cut the effect defined in our layout + elevation = 100F + // + animationStyle = R.style.AnimationMenu + //animationStyle = android.R.style.Animation_Dialog + + // Needed on Android 5 to make sure our pop-up can be dismissed by tapping outside and back button + // See: https://stackoverflow.com/questions/46872634/close-popupwindow-upon-tapping-outside-or-back-button + setBackgroundDrawable(ColorDrawable()) + + // Incognito status will be used to manage menu items visibility + iIsIncognito = (aBinding.root.context as WebBrowserActivity).isIncognito() + + //val radius: Float = getResources().getDimension(R.dimen.default_corner_radius) //32dp + + //iBinding.layoutMenuItems.layoutTransition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING) + //iBinding.layoutMenuItems.layoutTransition.disableTransitionType(LayoutTransition.CHANGE_APPEARING) + //iBinding.layoutMenuItems.layoutTransition.disableTransitionType(LayoutTransition.CHANGING) + + + //iBinding.layoutMenuItems.layoutTransition.setAnimator(LayoutTransition.CHANGE_APPEARING, animator) + //iBinding.layoutMenuItems.layoutTransition.setDuration(LayoutTransition.CHANGE_APPEARING, animator.duration) + //iBinding.layoutMenuItems.layoutTransition.setAnimator(LayoutTransition.CHANGING, animator) + //iBinding.layoutMenuItems.layoutTransition.setDuration(LayoutTransition.CHANGING, animator.duration) + + + /* + // TODO: That fixes the corner but leaves a square shadow behind + val toolbar: AppBarLayout = view.findViewById(R.id.header) + val materialShapeDrawable = toolbar.background as MaterialShapeDrawable + materialShapeDrawable.shapeAppearanceModel = materialShapeDrawable.shapeAppearanceModel + .toBuilder() + .setAllCorners(CornerFamily.ROUNDED, Utils.dpToPx(16F).toFloat()) + .build() + */ + + val hiltEntryPoint = EntryPointAccessors.fromApplication(iBinding.root.context.applicationContext, HiltEntryPoint::class.java) + bookmarkModel = hiltEntryPoint.bookmarkRepository + iUserPreferences = hiltEntryPoint.userPreferences + } + + val bookmarkModel: BookmarkRepository + val iUserPreferences: UserPreferences + + + + /** + * Scroll to the start of our menu. + * Could be the bottom or the top depending if we are using bottom toolbars. + * Default delay matches items animation. + */ + private fun scrollToStart(aDelay: Long = 300) { + iBinding.scrollViewItems.postDelayed( + { + if (contentView.context.configPrefs.toolbarsBottom) { + iBinding.scrollViewItems.smoothScrollTo(0, iBinding.scrollViewItems.height); + } else { + iBinding.scrollViewItems.smoothScrollTo(0, 0); + } + }, aDelay + ) + } + + /** + * Register click observer with the given menu item. + * This gave us the opportunity to dismiss the dialog… + * …but since we don't do that for all menu items anymore it is kinda useless. + */ + fun onMenuItemClicked(menuView: View, onClick: () -> Unit) { + menuView.setOnClickListener { + onClick() + } + } + + + /** + * Show menu items corresponding to our main menu. + */ + private fun applyMainMenuItemVisibility() { + // Reset items visibility + iBinding.layoutMenuItemsContainer.isVisible=true; + iBinding.menuItemWebPage.isVisible = true + // Basic items + iBinding.menuItemSessions.isVisible = !iIsIncognito + //iBinding.menuItemBookmarks.isVisible = true + iBinding.menuItemHistory.isVisible = true + iBinding.menuItemDownloads.isVisible = true + iBinding.menuItemNewTab.isVisible = true + iBinding.menuItemIncognito.isVisible = !iIsIncognito + iBinding.menuItemOptions.isVisible = true + iBinding.menuItemSettings.isVisible = !iIsIncognito + + + iBinding.menuItemExit.isVisible = iUserPreferences.menuShowExit || iIsIncognito + iBinding.menuItemNewTab.isVisible = iUserPreferences.menuShowNewTab + } + + /** + * Open up this popup menu + */ + fun show(aAnchor: View) { + + applyMainMenuItemVisibility() + + // Get our anchor location + val anchorLoc = IntArray(2) + aAnchor.getLocationInWindow(anchorLoc) + // Show our popup menu from the right side of the screen below our anchor + val gravity = if (contentView.context.configPrefs.toolbarsBottom) Gravity.BOTTOM or Gravity.RIGHT else Gravity.TOP or Gravity.RIGHT + val yOffset = if (contentView.context.configPrefs.toolbarsBottom) (contentView.context as WebBrowserActivity).iBinding.root.height - anchorLoc[1] - aAnchor.height else anchorLoc[1] + showAtLocation(aAnchor, gravity, + // Offset from the right screen edge + com.lin.magic.utils.Utils.dpToPx(10F), + // Above our anchor + yOffset) + + scrollToStart(0) + } + + companion object { + + fun inflate(layoutInflater: LayoutInflater): MenuMainBinding { + return MenuMainBinding.inflate(layoutInflater) + } + + } +} + diff --git a/app/src/main/java/com/lin/magic/browser/MenuWebPage.kt b/app/src/main/java/com/lin/magic/browser/MenuWebPage.kt new file mode 100644 index 00000000..f7c6741a --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/MenuWebPage.kt @@ -0,0 +1,196 @@ +package com.lin.magic.browser + +import com.lin.magic.R +import com.lin.magic.adblock.AbpUserRules +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.databinding.MenuWebPageBinding +import com.lin.magic.di.HiltEntryPoint +import com.lin.magic.di.configPrefs +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.isAppScheme +import com.lin.magic.utils.isSpecialUrl +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.PopupWindow +import androidx.core.view.isVisible +import dagger.hilt.android.EntryPointAccessors + + +class MenuWebPage : PopupWindow { + + val bookmarkModel: BookmarkRepository + val iUserPreferences: UserPreferences + val abpUserRules: AbpUserRules + + var iBinding: MenuWebPageBinding + + constructor(layoutInflater: LayoutInflater, aBinding: MenuWebPageBinding = inflate(layoutInflater)) + : super(aBinding.root, WRAP_CONTENT, WRAP_CONTENT, true) { + + //aBinding.root.context.injector.inject(this) + + iBinding = aBinding + + + // Elevation just need to be high enough not to cut the effect defined in our layout + elevation = 100F + // + animationStyle = R.style.AnimationMenu + //animationStyle = android.R.style.Animation_Dialog + + // Needed on Android 5 to make sure our pop-up can be dismissed by tapping outside and back button + // See: https://stackoverflow.com/questions/46872634/close-popupwindow-upon-tapping-outside-or-back-button + setBackgroundDrawable(ColorDrawable()) + + //val radius: Float = getResources().getDimension(R.dimen.default_corner_radius) //32dp + + //iBinding.layoutMenuItems.layoutTransition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING) + //iBinding.layoutMenuItems.layoutTransition.disableTransitionType(LayoutTransition.CHANGE_APPEARING) + //iBinding.layoutMenuItems.layoutTransition.disableTransitionType(LayoutTransition.CHANGING) + + + //iBinding.layoutMenuItems.layoutTransition.setAnimator(LayoutTransition.CHANGE_APPEARING, animator) + //iBinding.layoutMenuItems.layoutTransition.setDuration(LayoutTransition.CHANGE_APPEARING, animator.duration) + //iBinding.layoutMenuItems.layoutTransition.setAnimator(LayoutTransition.CHANGING, animator) + //iBinding.layoutMenuItems.layoutTransition.setDuration(LayoutTransition.CHANGING, animator.duration) + + + /* + // TODO: That fixes the corner but leaves a square shadow behind + val toolbar: AppBarLayout = view.findViewById(R.id.header) + val materialShapeDrawable = toolbar.background as MaterialShapeDrawable + materialShapeDrawable.shapeAppearanceModel = materialShapeDrawable.shapeAppearanceModel + .toBuilder() + .setAllCorners(CornerFamily.ROUNDED, Utils.dpToPx(16F).toFloat()) + .build() + */ + + val hiltEntryPoint = EntryPointAccessors.fromApplication(iBinding.root.context.applicationContext, HiltEntryPoint::class.java) + bookmarkModel = hiltEntryPoint.bookmarkRepository + iUserPreferences = hiltEntryPoint.userPreferences + abpUserRules = hiltEntryPoint.abpUserRules + + } + + /** + * Scroll to the start of our menu. + * Could be the bottom or the top depending if we are using bottom toolbars. + * Default delay matches items animation. + */ + private fun scrollToStart(aDelay: Long = 300) { + iBinding.scrollViewItems.postDelayed( + { + if (contentView.context.configPrefs.toolbarsBottom) { + iBinding.scrollViewItems.smoothScrollTo(0, iBinding.scrollViewItems.height); + } else { + iBinding.scrollViewItems.smoothScrollTo(0, 0); + } + }, aDelay + ) + } + + /** + * Register click observer with the given menu item. + * This gave us the opportunity to dismiss the dialog… + * …but since we don't do that for all menu items anymore it is kinda useless. + */ + fun onMenuItemClicked(menuView: View, onClick: () -> Unit) { + menuView.setOnClickListener { + onClick() + } + } + + /** + * Show menu items corresponding to our main menu. + */ + private fun applyMainMenuItemVisibility() { + + iBinding.layoutMenuItemsContainer.isVisible = true + // Those menu items are always on even for special URLs + iBinding.menuItemFind.isVisible = true + iBinding.menuItemPrint.isVisible = true + iBinding.menuItemReaderMode.isVisible = true + // Show option to go back to main menu + iBinding.menuItemMainMenu.isVisible = true + + (contentView.context as WebBrowserActivity).tabsManager.let { tm -> + tm.currentTab?.let { tab -> + // Let user add multiple times the same URL I guess, for now anyway + // Blocking it is not nice and subscription is more involved I guess + // See BookmarksDrawerView.updateBookmarkIndicator + //contentView.menuItemAddBookmark.visibility = if (bookmarkModel.isBookmark(tab.url).blockingGet() || tab.url.isSpecialUrl()) View.GONE else View.VISIBLE + var isSpecial = false + (!(tab.url.isSpecialUrl() || tab.url.isAppScheme())).let { + // Those menu items won't be displayed for special URLs + iBinding.menuItemDesktopMode.isVisible = it + iBinding.menuItemDarkMode.isVisible = it + iBinding.menuItemAddToHome.isVisible = it + iBinding.menuItemAddBookmark.isVisible = it + iBinding.menuItemShare.isVisible = it + iBinding.menuItemAdBlock.isVisible = it && iUserPreferences.adBlockEnabled + iBinding.menuItemTranslate.isVisible = it + isSpecial = !it + } + } + } + + if ((iBinding.root.context as? WebBrowserActivity)?.isIncognito() == true) { + // Incognito only works for that activity + // So no reader mode as it starts another activity + // TODO: We could try get reading mode working in incognito by creating another activity which starts in the same process I guess + iBinding.menuItemReaderMode.isVisible = false + // Also hide share and print as security feature when in incognito mode + iBinding.menuItemShare.isVisible = false + iBinding.menuItemPrint.isVisible = false + iBinding.menuItemAddToHome.isVisible = false + } + + scrollToStart() + } + + /** + * Open up this popup menu + */ + fun show(aAnchor: View) { + + applyMainMenuItemVisibility() + + + (contentView.context as WebBrowserActivity).tabsManager.let { + // Set desktop mode checkbox according to current tab + iBinding.menuItemDesktopMode.isChecked = it.currentTab?.desktopMode ?: false + // Same with dark mode + iBinding.menuItemDarkMode.isChecked = it.currentTab?.darkMode ?: false + // And ad block + iBinding.menuItemAdBlock.isChecked = it.currentTab?.url?.let { url -> !abpUserRules.isAllowed(Uri.parse(url)) } ?: false + } + + // Get our anchor location + val anchorLoc = IntArray(2) + aAnchor.getLocationInWindow(anchorLoc) + // Show our popup menu from the right side of the screen below our anchor + val gravity = if (contentView.context.configPrefs.toolbarsBottom) Gravity.BOTTOM or Gravity.RIGHT else Gravity.TOP or Gravity.RIGHT + val yOffset = if (contentView.context.configPrefs.toolbarsBottom) (contentView.context as WebBrowserActivity).iBinding.root.height - anchorLoc[1] - aAnchor.height else anchorLoc[1] + showAtLocation(aAnchor, gravity, + // Offset from the right screen edge + com.lin.magic.utils.Utils.dpToPx(10F), + // Above our anchor + yOffset) + + scrollToStart(0) + } + + companion object { + + fun inflate(layoutInflater: LayoutInflater): MenuWebPageBinding { + return MenuWebPageBinding.inflate(layoutInflater) + } + + } +} + diff --git a/app/src/main/java/com/lin/magic/browser/ProxyChoice.kt b/app/src/main/java/com/lin/magic/browser/ProxyChoice.kt new file mode 100644 index 00000000..0f069bfe --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/ProxyChoice.kt @@ -0,0 +1,14 @@ +package com.lin.magic.browser + +import com.lin.magic.settings.preferences.IntEnum + +/** + * The available proxy choices. + */ +enum class ProxyChoice(override val value: Int) : + IntEnum { + NONE(0), + ORBOT(1), + I2P(2), + MANUAL(3) +} diff --git a/app/src/main/java/com/lin/magic/browser/RecentTabsModel.kt b/app/src/main/java/com/lin/magic/browser/RecentTabsModel.kt new file mode 100644 index 00000000..77961b70 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/RecentTabsModel.kt @@ -0,0 +1,25 @@ +package com.lin.magic.browser + +import com.lin.magic.extensions.popIfNotEmpty +import android.os.Bundle +import java.util.* + +/** + * A model that saves [Bundle] and returns the last returned one. + */ +class RecentTabsModel { + + public val bundleStack: Stack = Stack() + + /** + * Return the last closed tab as a [Bundle] or null if there is no previously opened tab. + * Removes the [Bundle] from the queue after returning it. + */ + fun popLast(): Bundle? = bundleStack.popIfNotEmpty() + + /** + * Add the [savedBundle] to the queue. The next call to [popLast] will return this [Bundle]. + */ + fun add(savedBundle: Bundle) = bundleStack.add(savedBundle) + +} diff --git a/app/src/main/java/com/lin/magic/browser/SuggestionNumChoice.kt b/app/src/main/java/com/lin/magic/browser/SuggestionNumChoice.kt new file mode 100644 index 00000000..45adfffa --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/SuggestionNumChoice.kt @@ -0,0 +1,25 @@ +/* + * Copyright © 2020-2021 Jamal Rothfuchs + * Copyright © 2020-2021 Stéphane Lenclud + * Copyright © 2015 Anthony Restaino + */ + +package com.lin.magic.browser + +import com.lin.magic.settings.preferences.IntEnum + +/** + * The available Suggestion number choices. + */ +enum class SuggestionNumChoice(override val value: Int) : + IntEnum { + TWO(0), + THREE(1), + FOUR(2), + FIVE(3), + SIX(4), + SEVEN(5), + EIGHT(6), + NINE(7), + TEN(8) +} diff --git a/app/src/main/java/com/lin/magic/browser/TabModel.kt b/app/src/main/java/com/lin/magic/browser/TabModel.kt new file mode 100644 index 00000000..86f58d5e --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/TabModel.kt @@ -0,0 +1,85 @@ +package com.lin.magic.browser + +import com.lin.magic.extensions.createDefaultFavicon +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Bundle +import com.lin.magic.app +import java.io.ByteArrayOutputStream + +/** + * Tab model used to create a bundle from our tab. + * Used to persist information belonging to a tab. + */ +open class TabModel ( + var url : String, + var title : String, + var desktopMode: Boolean, + var darkMode: Boolean, + var favicon : Bitmap, + // Find in page search query + var searchQuery: String, + // Define if find in page search was active + var searchActive: Boolean, + // Actual WebView persisted bundle + var webView : Bundle? +) +{ + fun toBundle() : Bundle { + return Bundle(ClassLoader.getSystemClassLoader()).let { + it.putString(URL_KEY, url) + it.putString(TAB_TITLE_KEY, title) + it.putBundle(WEB_VIEW_KEY, webView) + it.putBoolean(KEY_DESKTOP_MODE, desktopMode) + it.putBoolean(KEY_DARK_MODE, darkMode) + it.putString(KEY_SEARCH_QUERY, searchQuery) + it.putBoolean(KEY_SEARCH_ACTIVE, searchActive) + favicon.apply { + // Crashlytics was showing our bitmap compression can lead to java.lang.IllegalStateException: Can't compress a recycled bitmap + // Therefore we now check if it was recycled before going ahead with compression. + // Otherwise we can still proceed without favicon anyway. + if (!isRecycled) + { + // Using PNG instead of WEBP as it is hopefully lossless + // Using WEBP results in the quality degrading reload after reload + // Maybe consider something like: https://stackoverflow.com/questions/8065050/convert-bitmap-to-byte-array-without-compress-method-in-android + val stream = ByteArrayOutputStream() + compress(Bitmap.CompressFormat.PNG, 100, stream) + val byteArray = stream.toByteArray() + it.putByteArray(TAB_FAVICON_KEY, byteArray) + } + } + it + } + } + + companion object { + const val KEY_SEARCH_ACTIVE = "SEARCH_ACTIVE" + const val KEY_SEARCH_QUERY = "SEARCH_QUERY" + const val KEY_DARK_MODE = "DARK_MODE" + const val KEY_DESKTOP_MODE = "DESKTOP_MODE" + const val URL_KEY = "URL" + const val TAB_TITLE_KEY = "TITLE" + const val TAB_FAVICON_KEY = "FAVICON" + const val WEB_VIEW_KEY = "WEB_VIEW" + } +} + +/** + * Used to create a Tab Model from a bundle. + */ +class TabModelFromBundle ( + var bundle : Bundle +): TabModel( + bundle.getString(URL_KEY)?:"", + bundle.getString(TAB_TITLE_KEY)?:"", + bundle.getBoolean(KEY_DESKTOP_MODE), + bundle.getBoolean(KEY_DARK_MODE), + bundle.getByteArray(TAB_FAVICON_KEY)?.let{BitmapFactory.decodeByteArray(it, 0, it.size)} + // That was needed for smooth transition was previous model where favicon could be null + // Past that transition it is just defensive code and should not execute anymore + ?:app.createDefaultFavicon(), + bundle.getString(KEY_SEARCH_QUERY)?:"", + bundle.getBoolean(KEY_SEARCH_ACTIVE), + bundle.getBundle(WEB_VIEW_KEY) +) diff --git a/app/src/main/java/com/lin/magic/browser/TabsManager.kt b/app/src/main/java/com/lin/magic/browser/TabsManager.kt new file mode 100644 index 00000000..036b751b --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/TabsManager.kt @@ -0,0 +1,1260 @@ +package com.lin.magic.browser + +import com.lin.magic.Entitlement +import com.lin.magic.R +import acr.browser.lightning.browser.sessions.Session +import com.lin.magic.constant.INTENT_ORIGIN +import com.lin.magic.extensions.snackbar +import com.lin.magic.search.SearchEngineProvider +import com.lin.magic.settings.NewTabPosition +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.ssl.SslState +import com.lin.magic.utils.* +import com.lin.magic.view.* +import android.app.Activity +import android.app.Application +import android.app.SearchManager +import android.content.Intent +import android.os.Bundle +import android.webkit.URLUtil +import androidx.lifecycle.LifecycleOwner +import com.lin.magic.Component +import com.lin.magic.utils.QUERY_PLACE_HOLDER +import com.lin.magic.utils.isBookmarkUrl +import com.lin.magic.utils.isDownloadsUrl +import com.lin.magic.utils.isHistoryUrl +import com.lin.magic.utils.isIncognitoPageUrl +import com.lin.magic.utils.isSpecialUrl +import com.lin.magic.utils.isStartPageUrl +import com.lin.magic.utils.smartUrlFilter +import com.lin.magic.view.BookmarkPageInitializer +import com.lin.magic.view.DownloadPageInitializer +import com.lin.magic.view.FreezableBundleInitializer +import com.lin.magic.view.HistoryPageInitializer +import com.lin.magic.view.HomePageInitializer +import com.lin.magic.view.IncognitoPageInitializer +import com.lin.magic.view.NoOpInitializer +import com.lin.magic.view.TabInitializer +import com.lin.magic.view.UrlInitializer +import com.lin.magic.view.WebPageTab +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber + +import java.io.File +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.collections.ArrayList + +/** + * A manager singleton that holds all the [WebPageTab] and tracks the current tab. It handles + * creation, deletion, restoration, state saving, and switching of tabs and sessions. + */ +//@HiltViewModel +@Singleton +class TabsManager @Inject constructor( + private val application: Application, + private val searchEngineProvider: SearchEngineProvider, + private val homePageInitializer: HomePageInitializer, + private val incognitoPageInitializer: IncognitoPageInitializer, + private val bookmarkPageInitializer: BookmarkPageInitializer, + private val historyPageInitializer: HistoryPageInitializer, + private val downloadPageInitializer: DownloadPageInitializer, + private val noOpPageInitializer: NoOpInitializer, + private val userPreferences: UserPreferences +): Component() { + + private val tabList = arrayListOf() + var iRecentTabs = mutableSetOf() + // This is just used when loading and saving sessions. + // TODO: Ideally it should not be a data member. + val savedRecentTabsIndices = mutableSetOf() + private var iIsIncognito = false; + + // Our persisted list of sessions + // TODO: Consider using a map instead of an array + var iSessions: ArrayList = arrayListOf() + var iCurrentSessionName: String = "" + set(value) { + // Most unoptimized way to maintain our current item but that should do for now + iSessions.forEach { s -> s.isCurrent = false } + iSessions.filter { s -> s.name == value}.apply {if (isNotEmpty()) get(0).isCurrent = true } + field = value + } + + /** + * Return the current [WebPageTab] or null if no current tab has been set. + * + * @return a [WebPageTab] or null if there is no current tab. + */ + var currentTab: WebPageTab? = null + private set + + private var tabNumberListeners = emptySet<(Int) -> Unit>() + + var isInitialized = false + private var postInitializationWorkList = mutableListOf() + + init { + + addTabNumberChangedListener { + // Update current session tab count + //TODO: Have a getCurrentSession function + //TODO: during shutdown initiated by session switch we get stray events here not matching the proper session since it current session name was changed + //TODO: it's no big deal and does no harm at all but still not consistent, we may want to fix it at some point + //TODO: after shutdown our tab counts are fixed by [loadSessions] + val session=iSessions.filter { s -> s.name == iCurrentSessionName } + if (session.isNotEmpty()) { + session[0].tabCount = it + } + } + } + + /* + override fun onCleared() { + super.onCleared() + shutdown() + app.tabsManager = null; + } + */ + + /** + * From [DefaultLifecycleObserver.onStop] + * + * This is called once our activity is not visible anymore. + * That's where we should save our data according to the docs. + * https://developer.android.com/guide/components/activities/activity-lifecycle#onstop + * Saving data can't wait for onDestroy as there is no guarantee onDestroy will ever be called. + * In fact even when user closes our Task from recent Task list our activity is just terminated without getting any notifications. + */ + override fun onStop(owner: LifecycleOwner) { + // Once we go background make sure the current tab is not new anymore + currentTab?.isNewTab = false + saveIfNeeded() + } + + override fun onDestroy(owner: LifecycleOwner) { + //shutdown() + } + + + /** + */ + fun currentSessionIndex() : Int { + return iSessions.indexOfFirst { s -> s.name == iCurrentSessionName } + } + + /** + */ + fun currentSession() : Session { + return session(iCurrentSessionName) + } + + /** + * Provide the session matching the given name + * TODO: have a better implementation + */ + fun session(aName: String) : Session { + if (iSessions.isNullOrEmpty()) { + // TODO: Return session with Default name + return Session() + } + + val list = iSessions.filter { s -> s.name == aName } + if (list.isNullOrEmpty()) { + // TODO: Return session with Default name + return Session() + } + + // Should only be one session item in that list + return list[0] + } + + + /** + * Adds a listener to be notified when the number of tabs changes. + */ + fun addTabNumberChangedListener(listener: ((Int) -> Unit)) { + tabNumberListeners += listener + } + + /** + * Cancels any pending work that was scheduled to run after initialization. + */ + fun cancelPendingWork() { + postInitializationWorkList.clear() + } + + + /** + * Executes the [runnable] once after the next time this manager has been initialized. + */ + fun doOnceAfterInitialization(runnable: () -> Unit) { + if (isInitialized) { + runnable() + } else { + postInitializationWorkList.add(object : + InitializationListener { + override fun onInitializationComplete() { + runnable() + postInitializationWorkList.remove(this) + } + }) + } + } + + /** + * Executes the [runnable] every time after this manager has been initialized. + */ + fun doAfterInitialization(runnable: () -> Unit) { + if (isInitialized) { + runnable() + } else { + postInitializationWorkList.add(object : + InitializationListener { + override fun onInitializationComplete() { + runnable() + } + }) + } + } + + /** + * + */ + private fun finishInitialization() { + + try { + if (allTabs.size == savedRecentTabsIndices.size) { // Defensive + // Populate our recent tab list from our persisted indices + iRecentTabs.clear() + // Looks like we can somehow persist -1 as a tab index + // TODO: That should never be the case. We ought to find out what's causing this. + // See: https://console.firebase.google.com/u/0/project/magic-b1f69/crashlytics/app/android:net.slions.magic.full.playstore/issues/d70a65025a98104878bf2da4aa06287e?time=last-seven-days&sessionEventKey=650AE750014800016260FF77850BA317_1859332239793370743 + savedRecentTabsIndices.forEach { iRecentTabs.add(allTabs.elementAt(it))} + } else { + // Defensive, if we have missing tabs in our recent tab list just reset it + resetRecentTabsList() + } + } + catch (ex: Exception) { + Timber.d("Failed to load recent tab list") + resetRecentTabsList() + } + + isInitialized = true + + // Iterate through our collection while allowing item to be removed and avoid ConcurrentModificationException + // To do that we need to make a copy of our list + val listCopy = postInitializationWorkList.toList() + for (listener in listCopy) { + listener.onInitializationComplete() + } + } + + /** + * + */ + private fun resetRecentTabsList() + { + Timber.d("resetRecentTabsList") + // Reset recent tabs list to arbitrary order + iRecentTabs.clear() + iRecentTabs.addAll(allTabs) + + // Put back current tab on top + currentTab?.let { + iRecentTabs.apply { + remove(it) + add(it) + } + } + } + + /** + * Initialize the state of the [TabsManager] based on previous state of the browser. + * + * TODO: See how you can offload IO to a background thread + */ + fun initializeTabs(activity: Activity, incognito: Boolean) : MutableList { + Timber.d("initializeTabs") + iIsIncognito = incognito + + shutdown() + + val list = mutableListOf() + + if (incognito) { + list.add(newTab(activity, incognitoPageInitializer, incognito, NewTabPosition.END_OF_TAB_LIST)) + } else { + tryRestorePreviousTabs(activity).forEach { + try { + list.add(newTab(activity, it, incognito, NewTabPosition.END_OF_TAB_LIST)) + } catch (ex: Throwable) { + // That's a corrupted session file, can happen when importing garbage. + activity.snackbar(R.string.error_session_file_corrupted) + } + } + + // Make sure we have one tab + if (list.isEmpty()) { + list.add(newTab(activity, homePageInitializer, incognito, NewTabPosition.END_OF_TAB_LIST)) + } + } + + finishInitialization() + + return list + } + + /** + * Returns the URL for a search [Intent]. If the query is empty, then a null URL will be + * returned. + */ + fun extractSearchFromIntent(intent: Intent): String? { + val query = intent.getStringExtra(SearchManager.QUERY) + val searchUrl = "${searchEngineProvider.provideSearchEngine().queryUrl}$QUERY_PLACE_HOLDER" + + return if (query?.isNotBlank() == true) { + smartUrlFilter(query, true, searchUrl).first + } else { + null + } + } + + + /** + * Load tabs from the given file + */ + private fun loadSession(aFilename: String): MutableList + { + val bundle = com.lin.magic.utils.FileUtils.readBundleFromStorage(application, aFilename) + + // Defensive. should have happened in the shutdown already + savedRecentTabsIndices.clear() + // Read saved current tab index if any + bundle?.let{ + it.getIntArray(RECENT_TAB_INDICES)?.toList()?.let { it1 -> savedRecentTabsIndices.addAll(it1) } + } + + val list = mutableListOf() + readSavedStateFromDisk(bundle).forEach { + list.add(if (it.url.isSpecialUrl()) { + tabInitializerForSpecialUrl(it.url) + } else { + FreezableBundleInitializer(it) + }) + } + + // Make sure we have at least one tab + if (list.isEmpty()) { + list.add(homePageInitializer) + } + return list + } + + /** + * Create a recovery session + */ + private fun loadRecoverySession(): MutableList + { + // Defensive. should have happened in the shutdown already + savedRecentTabsIndices.clear() + val list = mutableListOf() + + // Make sure we have at least one tab + if (list.isEmpty()) { + list.add(noOpPageInitializer) + } + return list + } + + + /** + * Rename the session [aOldName] to [aNewName]. + * Takes care of checking parameters validity before proceeding. + * Changes current session name if needed. + * Rename matching session data file too. + * Commit session list changes to persistent storage. + * + * @param [aOldName] Name of the session to rename in our session list. + * @param [aNewName] New name to be assumed by specified session. + */ + fun renameSession(aOldName: String, aNewName: String) { + + Timber.d("Try rename session $aOldName to $aNewName") + + val index = iSessions.indexOf(session(aOldName)) + Timber.d("Session index $index") + + // Check if we can indeed rename that session + if (iSessions.isEmpty() // Check if we have sessions at all + or !isValidSessionName(aNewName) // Check if new session name is valid + or !(index>=0 && index s.name == aName }.isNullOrEmpty() + } + } + + + /** + * Returns an observable that emits the [TabInitializer] for each previously opened tab as + * saved on disk. Can potentially be empty. + */ + private fun restorePreviousTabs(): MutableList + { + //throw Exception("Hi There!") + // First load our sessions + loadSessions() + // Check if we have a current session + if (iCurrentSessionName.isBlank()) { + // No current session name meaning first load with version support + // Add our default session + iCurrentSessionName = application.getString(R.string.session_default) + // At this stage we must have at least an empty list + iSessions.add(Session(iCurrentSessionName)) + // Than load legacy session file to make sure tabs from earlier version are preserved + return loadSession(FILENAME_SESSION_DEFAULT) + // TODO: delete legacy session file at some point + } else { + // Load current session then + return loadSession(fileNameFromSessionName(iCurrentSessionName)) + } + } + + /** + * Safely restore previous tabs + */ + private fun tryRestorePreviousTabs(activity: Activity): MutableList + { + return try { + restorePreviousTabs() + } catch (ex: Throwable) { + // TODO: report this using firebase or local crash logs + Timber.e(ex,"restorePreviousTabs failed") + activity.snackbar(R.string.error_recovery_session) + createRecoverySession() + } + } + + + /** + * Called whenever we fail to load a session properly. + * The idea is that it should enable the app to start even when it's pointing to a corrupted session. + */ + private fun createRecoverySession(): MutableList + { + recoverSessions() + // Add our recovery session using timestamp + iCurrentSessionName = application.getString(R.string.session_recovery) + "-" + Date().time + iSessions.add(Session(iCurrentSessionName,1, true)) + + return loadRecoverySession() + } + + + + + /** + * Provide a tab initializer for the given special URL + */ + fun tabInitializerForSpecialUrl(url: String): TabInitializer { + return when { + url.isBookmarkUrl() -> bookmarkPageInitializer + url.isDownloadsUrl() -> downloadPageInitializer + url.isStartPageUrl() -> homePageInitializer + url.isIncognitoPageUrl() -> incognitoPageInitializer + url.isHistoryUrl() -> historyPageInitializer + else -> homePageInitializer + } + } + + /** + * Method used to resume all the tabs in the browser. This is necessary because we cannot pause + * the WebView when the application is open currently due to a bug in the WebView, where calling + * onResume doesn't consistently resume it. + */ + fun resumeAll() { + currentTab?.resumeTimers() + for (tab in tabList) { + tab.onResume() + tab.initializePreferences() + } + } + + /** + * Method used to pause all the tabs in the browser. This is necessary because we cannot pause + * the WebView when the application is open currently due to a bug in the WebView, where calling + * onResume doesn't consistently resume it. + */ + fun pauseAll() { + currentTab?.pauseTimers() + tabList.forEach(WebPageTab::onPause) + } + + /** + * Return the tab at the given position in tabs list, or null if position is not in tabs list + * range. + * + * @param position the index in tabs list + * @return the corespondent [WebPageTab], or null if the index is invalid + */ + fun getTabAtPosition(position: Int): WebPageTab? = + if (position < 0 || position >= tabList.size) { + null + } else { + tabList[position] + } + + val allTabs: List + get() = tabList + + /** + * Shutdown the manager. This destroys all tabs and clears the references to those tabs. + * Current tab is also released for garbage collection. + */ + fun shutdown() { + Timber.d("shutdown") + repeat(tabList.size) { doDeleteTab(0) } + savedRecentTabsIndices.clear() + isInitialized = false + currentTab = null + } + + /** + * The current number of tabs in the manager. + * + * @return the number of tabs in the list. + */ + fun size(): Int = tabList.size + + /** + * The index of the last tab in the manager. + * + * @return the last tab in the list or -1 if there are no tabs. + */ + fun last(): Int = tabList.size - 1 + + + /** + * The last tab in the tab manager. + * + * @return the last tab, or null if there are no tabs. + */ + fun lastTab(): WebPageTab? = tabList.lastOrNull() + + /** + * Create and return a new tab. The tab is automatically added to the tabs list. + * + * @param activity the activity needed to create the tab. + * @param tabInitializer the initializer to run on the tab after it's been created. + * @param isIncognito whether the tab is an incognito tab or not. + * @return a valid initialized tab. + */ + fun newTab( + activity: Activity, + tabInitializer: TabInitializer, + isIncognito: Boolean, + newTabPosition: NewTabPosition + ): WebPageTab { + Timber.i("New tab") + val tab = WebPageTab( + activity, + tabInitializer, + isIncognito, + homePageInitializer, + incognitoPageInitializer, + bookmarkPageInitializer, + downloadPageInitializer, + historyPageInitializer + ) + + // Add our new tab at the specified position + when(newTabPosition){ + NewTabPosition.BEFORE_CURRENT_TAB -> tabList.add(indexOfCurrentTab(), tab) + NewTabPosition.AFTER_CURRENT_TAB -> tabList.add(indexOfCurrentTab() + 1, tab) + NewTabPosition.START_OF_TAB_LIST -> tabList.add(0, tab) + NewTabPosition.END_OF_TAB_LIST -> tabList.add(tab) + } + + tabNumberListeners.forEach { it(size()) } + return tab + } + + /** + * Removes a tab from the list and destroys the tab. If the tab removed is the current tab, the + * reference to the current tab will be nullified. + * + * @param position The position of the tab to remove. + */ + private fun removeTab(position: Int) { + if (position >= tabList.size) { + return + } + + val tab = tabList.removeAt(position) + iRecentTabs.remove(tab) + if (currentTab == tab) { + currentTab = null + } + tab.destroy() + } + + /** + * Deletes a tab from the manager. If the tab being deleted is the current tab, this method will + * switch the current tab to a new valid tab. + * + * @param position the position of the tab to delete. + * @return returns true if the current tab was deleted, false otherwise. + */ + private fun doDeleteTab(position: Int): Boolean { + Timber.i("doDeleteTab: $position") + val currentTab = currentTab + val current = positionOf(currentTab) + + if (current == position) { + when { + size() == 1 -> this.currentTab = null + // Switch to previous tab + else -> switchToTab(indexOfTab(iRecentTabs.elementAt(iRecentTabs.size - 2))) + } + } + + removeTab(position) + tabNumberListeners.forEach { it(size()) } + return current == position + } + + /** + * Return the position of the given tab. + * + * @param tab the tab to look for. + * @return the position of the tab or -1 if the tab is not in the list. + */ + fun positionOf(tab: WebPageTab?): Int = tabList.indexOf(tab) + + + /** + * Save our states if needed. + */ + private fun saveIfNeeded() { + if (iIsIncognito) { + // We don't persist anything when browsing incognito + return + } + + if (userPreferences.restoreTabsOnStartup) { + saveState() + } + else { + clearSavedState() + } + } + + /** + * Saves the state of the current WebViews, to a bundle which is then stored in persistent + * storage and can be unparceled. + */ + fun saveState() { + Timber.d("saveState") + + // Fix bug where all tabs would get lost + // See: https://github.com/Slion/Magic/issues/193 + if (!isInitialized) { + Timber.d("saveState - Don't do that") + return + } + + // Save sessions info + saveSessions() + // Save our session + saveCurrentSession(iCurrentSessionName) + } + + /** + * Save current session including WebView tab states and recent tab list in the specified file. + */ + private fun saveCurrentSession(aName: String) { + Timber.d("saveCurrentSession - $aName") + val outState = Bundle(ClassLoader.getSystemClassLoader()) + tabList + .withIndex() + .forEach { (index, tab) -> + // Index padding with zero to make sure they are restored in the correct order + // That gives us proper sorting up to 99999 tabs which should be more than enough :) + outState.putBundle(TAB_KEY_PREFIX + String.format("%05d", index), tab.saveState()) + } + + //Now save our recent tabs + // Create an array of tab indices from our recent tab list to be persisted + savedRecentTabsIndices.clear() + iRecentTabs.forEach { savedRecentTabsIndices.add(indexOfTab(it))} + outState.putIntArray(RECENT_TAB_INDICES, savedRecentTabsIndices.toIntArray()) + + // Write our bundle to disk + iScopeThreadPool.launch { + // Guessing delay is not needed since we do not use the main thread scope anymore + //delay(1L) + val temp = FILENAME_TEMP_PREFIX + aName + val backup = FILENAME_BACKUP_PREFIX + aName + val session = FILENAME_SESSION_PREFIX + aName + + // Save to temporary session file + com.lin.magic.utils.FileUtils.writeBundleToStorage(application, outState, temp) + // Defensively delete session backup, that should never be needed really + com.lin.magic.utils.FileUtils.deleteBundleInStorage(application, backup) + // Rename our session file as backup + com.lin.magic.utils.FileUtils.renameBundleInStorage(application, session, backup) + // Rename our temporary session to actual session + com.lin.magic.utils.FileUtils.renameBundleInStorage(application, temp, session) + // Delete session backup + com.lin.magic.utils.FileUtils.deleteBundleInStorage(application, backup) + + // We used that loop to test that our jobs are completed no matter what when the app is closed. + // However long running tasks could run into race condition I guess if we queue it multiple times. + // I really don't understand what's going on exactly when we close the app twice and we have two instances of that job running. + // It looks like the process was not terminated when exiting the app the first time and both jobs are running in different thread on the same process. + // Though even when waiting for the end of that job before restarting the app Android can reuse that process anyway… + // Log example: + // date time PID-TID/package priority/tag: message + // 2022-01-11 11:32:59.939 23094-23207/net.slions.magic.full.download.debug D/TabsManager: Tick: 28 + // 2022-01-11 11:33:00.224 23094-23208/net.slions.magic.full.download.debug D/TabsManager: Tick: 20 +// repeat(30) { +// delay(1000L) +// logger.log(TAG, "Tick: $it") +// } + } + } + + /** + * Provide session file name from session name + */ + private fun fileNameFromSessionName(aSessionName: String) : String { + return FILENAME_SESSION_PREFIX + aSessionName + } + + /** + * Provide session file from session name + */ + fun fileFromSessionName(aName: String) : File { + return File(application.filesDir, fileNameFromSessionName(aName)) + } + + /** + * Use this method to clear the saved state if you do not wish it to be restored when the + * browser next starts. + */ + fun clearSavedState() = com.lin.magic.utils.FileUtils.deleteBundleInStorage(application, FILENAME_SESSION_DEFAULT) + + /** + * + */ + fun deleteSession(aSessionName: String) { + + // TODO: handle case where we delete current session + if (aSessionName == iCurrentSessionName) { + // Can't do that for now + return + } + + val index = iSessions.indexOf(session(aSessionName)) + // Delete session file + com.lin.magic.utils.FileUtils.deleteBundleInStorage(application, fileNameFromSessionName(iSessions[index].name)) + // Remove session from our list + iSessions.removeAt(index) + } + + + /** + * Save our session list and current session name to disk. + */ + fun saveSessions() { + Timber.d("saveState") + val bundle = Bundle(javaClass.classLoader) + bundle.putString(KEY_CURRENT_SESSION, iCurrentSessionName) + bundle.putParcelableArrayList(KEY_SESSIONS, iSessions) + // Write our bundle to disk + iScopeThreadPool.launch { + // Guessing delay is not needed since we do not use the main thread scope anymore + //delay(1L) + com.lin.magic.utils.FileUtils.writeBundleToStorage(application, bundle, FILENAME_SESSIONS) + } + } + + /** + * Just the sessions list really + */ + fun deleteSessions() { + com.lin.magic.utils.FileUtils.deleteBundleInStorage(application, FILENAME_SESSIONS) + } + + /** + * Load our session list and current session name from disk. + */ + private fun loadSessions() { + val bundle = com.lin.magic.utils.FileUtils.readBundleFromStorage(application, FILENAME_SESSIONS) + + bundle?.apply{ + getParcelableArrayList(KEY_SESSIONS)?.let { iSessions = it} + // Sessions must have been loaded when we load that guys + getString(KEY_CURRENT_SESSION)?.let {iCurrentSessionName = it} + } + + // Somehow we lost that file again :) + // That crazy bug we keep chasing after + // TODO: consider running recovery even when our session list was loaded + if (iSessions.isEmpty()) { + recoverSessions() + // Set the first one as current one + if (iSessions.isNotEmpty()) { + iCurrentSessionName = iSessions[0].name + } + } + } + + /** + * Reset our session collection and repopulate by searching the file system for session files. + */ + private fun recoverSessions() { + // TODO: report this in firebase or local logs + Timber.i("recoverSessions") + // + iSessions.clear() // Defensive, should already be empty if we get there + // Search for session files + val files = application.filesDir?.let{it.listFiles { d, name -> name.startsWith(FILENAME_SESSION_PREFIX) }} + // Add recovered sessions to our collection + files?.forEach { f -> iSessions.add(Session(f.name.substring(FILENAME_SESSION_PREFIX.length), -1)) } + } + + /** + * + */ + private fun readSavedStateFromDisk(aBundle: Bundle?): MutableList { + + val list = mutableListOf() + aBundle?.keySet() + ?.filter { it.startsWith(TAB_KEY_PREFIX) } + ?.mapNotNull { bundleKey -> + aBundle.getBundle(bundleKey)?.let { list.add(TabModelFromBundle(it))} + } + + return list; + } + + + /** + * Returns the index of the current tab. + * + * @return Return the index of the current tab, or -1 if the current tab is null. + */ + fun indexOfCurrentTab(): Int = tabList.indexOf(currentTab) + + /** + * Returns the index of the tab. + * + * @return Return the index of the tab, or -1 if the tab isn't in the list. + */ + fun indexOfTab(tab: WebPageTab): Int = tabList.indexOf(tab) + + /** + * Returns the [WebPageTab] with the provided hash, or null if there is no tab with the hash. + * + * @param hashCode the hashcode. + * @return the tab with an identical hash, or null. + */ + fun getTabForHashCode(hashCode: Int): WebPageTab? = + tabList.firstOrNull { webPageTab -> webPageTab.webView?.let { it.hashCode() == hashCode } == true } + + /** + * Switch from the current tab to the one at the given [aPosition]. + * + * @param aPosition Index of the tab we want to switch to. + * @exception IndexOutOfBoundsException if the provided index is out of range. + * @return The selected tab we just switched to. + */ + fun switchToTab(aPosition: Int): WebPageTab { + Timber.i("switch to tab: $aPosition") + return tabList[aPosition].also { + currentTab = it + // Put that tab at the top of our recent tab list + iRecentTabs.apply{ + remove(it) + add(it) + } + //logger.log(TAG, "Recent indices: $recentTabsIndices") + } + } + + /** + * Was needed instead of simple runnable to be able to implement run once after init function + */ + interface InitializationListener { + fun onInitializationComplete() + } + + /////////////////// + // From here we have the former browser presenter stuff + /////////////////// + + private var currentTabFromPresenter: WebPageTab? = null + private var shouldClose: Boolean = false + + lateinit var iWebBrowser: WebBrowser + var isIncognito: Boolean = false + lateinit var closedTabs: RecentTabsModel + + /** + * Switch to the session with the given name + */ + fun switchToSession(aSessionName: String) { + // Don't do anything if given session name is already the current one or if such session does not exists + if (!isInitialized + || iCurrentSessionName==aSessionName + || iSessions.none { s -> s.name == aSessionName } + ) { + return + } + + // Save current states + saveState() + // + isInitialized = false + // Change current session + iCurrentSessionName = aSessionName + // Save it again to preserve new current session name + saveSessions() + // Then reload our tabs + setupTabs() + + // TODO: Using toast should really be avoided as they pileup + // TODO: Doing this here is also wrong as we do not know yet if our session was loaded correctly + // TODO: Give some user feedback yes but please do it properly + //app.apply { + // toast(getString(R.string.session_switched,aSessionName)) + //} + } + + + /** + * Initializes our tab manager. + */ + fun setupTabs(aIntent: Intent? = null) { + Timber.d("setupTabs") + iScopeMainThread.launch { + delay(1L) + val tabs = initializeTabs(iWebBrowser as Activity, isIncognito) + // At this point we always have at least a tab in the tab manager + iWebBrowser.notifyTabViewInitialized() + iWebBrowser.updateTabNumber(size()) + // Switch to persisted current tab + tabChanged(if (savedRecentTabsIndices.isNotEmpty()) savedRecentTabsIndices.last() else positionOf(tabs.last()),false, false) + // Only then can we open tab from external app on startup otherwise it is opened in the background somehow + aIntent?.let {onNewIntent(aIntent)} + + //logger.log(TAG,"After from coroutine") + } + + //logger.log(TAG,"After from main") + } + + /** + * Called when the foreground is changing. + * + * [aTab] The tab we are switching to. + * [aWasTabAdded] True if [aTab] was just created. + * [aGoingBack] Tells in which direction we are going, this can help determine what kind of tab animation will be used. + */ + private fun onTabChanged(aTab: WebPageTab, aWasTabAdded: Boolean, aPreviousTabClosed: Boolean, aGoingBack: Boolean) { + Timber.d("onTabChanged") + + currentTabFromPresenter?.let { + // TODO: Restore this when Google fixes the bug where the WebView is + // blank after calling onPause followed by onResume. + // it.onPause(); + it.isForeground = false + } + + // Must come first so that frozen tabs are unfrozen + // This will create frozen tab WebView, before that WebView is not available + aTab.isForeground = true + + aTab.resumeTimers() + aTab.onResume() + + iWebBrowser.setBackButtonEnabled(aTab.canGoBack()) + iWebBrowser.setForwardButtonEnabled(aTab.canGoForward()) + iWebBrowser.updateUrl(aTab.url, false) + iWebBrowser.setTabView(aTab.webView!!,aWasTabAdded,aPreviousTabClosed, aGoingBack) + val index = indexOfTab(aTab) + if (index >= 0) { + iWebBrowser.notifyTabViewChanged(indexOfTab(aTab)) + } + + // Must come late as it needs a webview + iWebBrowser.updateSslState(aTab.currentSslState() ?: SslState.None) + + currentTabFromPresenter = aTab + } + + /** + * Closes all tabs but the current tab. + */ + fun closeAllOtherTabs() { + Timber.d("closeAllOtherTabs") + while (last() != indexOfCurrentTab()) { + deleteTab(last()) + } + + while (0 != indexOfCurrentTab()) { + deleteTab(0) + } + } + + /** + * SL: That's not quite working for some reason. + * Close all tabs + */ + fun closeAllTabs() { + // That should never be the case though + if (allTabs.count()==0) return + + while (allTabs.count() > 1) { + deleteTab(last()) + } + + //deleteTab(last()) + } + + /** + * Deletes the tab at the specified position. + * + * @param position the position at which to delete the tab. + */ + fun deleteTab(position: Int) { + Timber.d("deleteTab") + val tabToDelete = getTabAtPosition(position) ?: return + + closedTabs.add(tabToDelete.saveState()) + + val isShown = tabToDelete.isShown + val shouldClose = shouldClose && isShown && tabToDelete.isNewTab + val beforeTab = currentTab + + val currentDeleted = doDeleteTab(position) + if (currentDeleted) { + tabChanged(indexOfCurrentTab(), isShown, false) + } + + val afterTab = currentTab + iWebBrowser.notifyTabViewRemoved(position) + + if (afterTab == null) { + iWebBrowser.closeBrowser() + return + } else if (afterTab !== beforeTab) { + iWebBrowser.notifyTabViewChanged(indexOfCurrentTab()) + } + + if (shouldClose && !isIncognito) { + this.shouldClose = false + iWebBrowser.closeActivity() + } + + iWebBrowser.updateTabNumber(size()) + + Timber.d("deleteTab - end") + } + + /** + * Handle a new intent from the the main BrowserActivity. + * TODO: That implementation is so ugly… try and improve that. + * @param intent the intent to handle, may be null. + */ + fun onNewIntent(intent: Intent?) = doOnceAfterInitialization { + val url = if (intent?.action == Intent.ACTION_WEB_SEARCH) { + extractSearchFromIntent(intent) + } + else if (intent?.action == Intent.ACTION_SEND) { + // User shared text with our app + if ("text/plain" == intent.type) { + // Get shared text + val clue = intent.getStringExtra(Intent.EXTRA_TEXT) + // Put it in the address bar if any + clue?.let { iWebBrowser.setAddressBarText(it) } + } + // Cancel other operation as we won't open a tab here + null + } else { + intent?.dataString + } + + val tabHashCode = intent?.extras?.getInt(INTENT_ORIGIN, 0) ?: 0 + + if (tabHashCode != 0 && url != null) { + getTabForHashCode(tabHashCode)?.loadUrl(url) + } else if (url != null) { + if (URLUtil.isFileUrl(url)) { + iWebBrowser.showBlockedLocalFileDialog { + newTab(UrlInitializer(url), true) + shouldClose = true + lastTab()?.isNewTab = true + } + } else { + newTab(UrlInitializer(url), true) + shouldClose = true + lastTab()?.isNewTab = true + } + } + } + + /** + * Recover last closed tab. + */ + fun recoverClosedTab(show: Boolean = true) { + closedTabs.popLast()?.let { bundle -> + TabModelFromBundle(bundle).let { + if (it.url.isSpecialUrl()) { + // That's a special URL + newTab(tabInitializerForSpecialUrl(it.url), show) + } else { + // That's an actual WebView bundle + newTab(FreezableBundleInitializer(it), show) + } + } + iWebBrowser.showSnackbar(R.string.reopening_recent_tab) + } + } + + /** + * Recover all closed tabs + */ + fun recoverAllClosedTabs() { + while (closedTabs.bundleStack.count()>0) { + recoverClosedTab(false) + } + } + + /** + * Loads a URL in the current tab. + * + * @param url the URL to load, must not be null. + */ + fun loadUrlInCurrentView(url: String) { + currentTab?.loadUrl(url) + } + + /** + * Notifies the presenter that we wish to switch to a different tab at the specified position. + * If the position is not in the model, this method will do nothing. + * + * [position] the position of the tab to switch to. + * [aPreviousTabClosed] Tells if the previous tab was closed, this can help determine what kind of tab animation will be used. + * [aGoingBack] Tells in which direction we are going, this can help determine what kind of tab animation will be used. + */ + fun tabChanged(position: Int, aPreviousTabClosed: Boolean, aGoingBack: Boolean) { + if (position < 0 || position >= size()) { + Timber.d("tabChanged invalid position: $position") + return + } + + Timber.d("tabChanged: $position") + onTabChanged(switchToTab(position),false, aPreviousTabClosed, aGoingBack) + } + + + + + /** + * Open a new tab with the specified URL. You can choose to show the tab or load it in the + * background. + * + * @param tabInitializer the tab initializer to run after the tab as been created. + * @param show whether or not to switch to this tab after opening it. + * @return true if we successfully created the tab, false if we have hit max tabs. + */ + fun newTab(tabInitializer: TabInitializer, show: Boolean): Boolean { + // Limit number of tabs according to sponsorship level + if (size() >= Entitlement.maxTabCount(userPreferences.sponsorship)) { + iWebBrowser.onMaxTabReached() + // Still allow spawning more tabs for the time being. + // That means not having a valid subscription will only spawn that annoying message above. + //return false + } + + Timber.d("New tab, show: $show") + + val startingTab = newTab(iWebBrowser as Activity, tabInitializer, isIncognito, userPreferences.newTabPosition) + if (size() == 1) { + startingTab.resumeTimers() + } + + iWebBrowser.notifyTabViewAdded() + iWebBrowser.updateTabNumber(size()) + + if (show) { + onTabChanged(switchToTab(indexOfTab(startingTab)),true, false, false) + } + else { + // We still need to add it to our recent tabs + // Adding at the beginning of a Set is doggy though + val recentTabs = iRecentTabs.toSet() + iRecentTabs.clear() + iRecentTabs.add(startingTab) + iRecentTabs.addAll(recentTabs) + } + + return true + } + + companion object { + + private const val TAB_KEY_PREFIX = "TAB_" + // Preserve this file name for compatibility + private const val FILENAME_SESSION_DEFAULT = "SAVED_TABS.parcel" + private const val KEY_CURRENT_SESSION = "KEY_CURRENT_SESSION" + private const val KEY_SESSIONS = "KEY_SESSIONS" + private const val FILENAME_SESSIONS = "SESSIONS" + const val FILENAME_SESSION_PREFIX = "SESSION_" + const val FILENAME_TEMP_PREFIX = "TEMP_SESSION_" + const val FILENAME_BACKUP_PREFIX = "BACKUP_SESSION_" + + private const val RECENT_TAB_INDICES = "RECENT_TAB_INDICES" + + } + +} diff --git a/app/src/main/java/com/lin/magic/browser/TabsView.kt b/app/src/main/java/com/lin/magic/browser/TabsView.kt new file mode 100644 index 00000000..4860481b --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/TabsView.kt @@ -0,0 +1,41 @@ +package com.lin.magic.browser + +/** + * The interface for communicating to the tab list view. + */ +interface TabsView { + + /** + * Called when a tab has been added. + */ + fun tabAdded() + + /** + * Called when a tab has been removed. + * + * @param position the position of the tab that has been removed. + */ + fun tabRemoved(position: Int) + + /** + * Called when a tab's metadata has been changed. + * + * @param position the position of the tab that has been changed. + */ + fun tabChanged(position: Int) + + /** + * Called when the tabs are completely initialized for the first time. + */ + fun tabsInitialized() + + /** + * Enables and disables the go back button. + */ + fun setGoBackEnabled(isEnabled: Boolean) + + /** + * Enables and disables the go forward button. + */ + fun setGoForwardEnabled(isEnabled: Boolean) +} diff --git a/app/src/main/java/com/lin/magic/browser/WebBrowser.kt b/app/src/main/java/com/lin/magic/browser/WebBrowser.kt new file mode 100644 index 00000000..08bfbf36 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/WebBrowser.kt @@ -0,0 +1,291 @@ +package com.lin.magic.browser + +import com.lin.magic.database.Bookmark +import com.lin.magic.dialog.LightningDialogBuilder +import com.lin.magic.ssl.SslState +import com.lin.magic.view.WebPageTab +import android.content.pm.ActivityInfo +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Message +import android.view.View +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import androidx.annotation.ColorInt +import androidx.annotation.IdRes +import androidx.annotation.StringRes + +/** + * TODO: Find a proper name for that class + */ +interface WebBrowser { + + /** + * Called when our current tab view needs to be changed. + * Implementer typically will remove the currently bound tab view and hook the one provided here. + * + * [aView] is in fact a WebViewEx however this could change. + * [aWasTabAdded] True if [aView] is a newly created tab. + * [aPreviousTabClosed] True if the current foreground tab [aView] will replaced was closed. + * [aGoingBack] True if we are going back rather than forward in our tab cycling. + */ + fun setTabView(aView: View, aWasTabAdded: Boolean, aPreviousTabClosed: Boolean, aGoingBack: Boolean) + + + fun updateTabNumber(number: Int) + + + fun closeBrowser() + + fun closeActivity() + + fun showBlockedLocalFileDialog(onPositiveClick: () -> Unit) + + fun showSnackbar(@StringRes resource: Int) + + fun notifyTabViewRemoved(position: Int) + + fun notifyTabViewAdded() + + fun notifyTabViewChanged(position: Int) + + fun notifyTabViewInitialized() + + /** + * Triggered whenever user reaches the maximum amount of tabs allowed by her license. + * Should typically display a message warning the user about it. + */ + fun onMaxTabReached() + + /** + * Set the browser address bar text. + */ + fun setAddressBarText(aText: String) + + /** + * @return the current color of the UI as a color integer. + */ + @ColorInt + fun getUiColor(): Int + + /** + * @return true if color mode is enabled, false otherwise. + */ + fun isColorMode(): Boolean + + /** + * @return the tab model which contains all the tabs presented to the user. + */ + fun getTabModel(): TabsManager + + /** + * Notifies the controller of a change in the favicon, indicating that the UI should adapt to + * the color of the favicon. + * + * @param favicon the new favicon + * @param color meta theme color as specified in HTML + * @param tabBackground the background of the tab, only used when tabs are not displayed in the + * drawer. + */ + fun changeToolbarBackground(favicon: Bitmap?, color: Int, tabBackground: Drawable?) + + /** + * Updates the current URL of the page. + * + * @param url the current URL. + * @param isLoading true if the [url] is currently being loaded, false otherwise. + */ + fun updateUrl(url: String?, isLoading: Boolean) + + /** + * Update the loading progress of the page. + * + * @param aProgress the loading progress of the page, an integer between 0 and 100. + */ + fun onProgressChanged(aTab: WebPageTab, aProgress: Int) + + /** + * Notify the controller that a [url] has been visited. + * + * @param title the optional title of the current page being viewed. + * @param url the URL of the page being viewed. + */ + fun updateHistory(title: String?, url: String) + + /** + * Notify the controller that it should open the file chooser with the provided callback. + */ + fun openFileChooser(uploadMsg: ValueCallback) + + /** + * Notify the controller that it should open the file chooser with the provided callback. + */ + fun showFileChooser(filePathCallback: ValueCallback>) + + /** + * Notify the controller that it should display the custom [view] in full-screen to the user. + */ + fun onShowCustomView( + view: View, + callback: WebChromeClient.CustomViewCallback, + requestedOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_SENSOR + ) + + /** + * Notify the controller that it should hide the custom view which was previously displayed in + * full screen to the user. + */ + fun onHideCustomView() + + /** + * Called when a website wants to open a link in a new window. + * + * @param resultMsg the message to send to the new web view that is created. + */ + fun onCreateWindow(resultMsg: Message) + + /** + * Notify the browser that the website currently being displayed by the [tab] wants to be + * closed. + */ + fun onCloseWindow(tab: WebPageTab) + + /** + * Hide the search bar from view via animation. + */ + fun hideActionBar() + + /** + * Show the search bar via animation. + */ + fun showActionBar() + + /** + * Show the close browser dialog for the tab at [position]. + */ + fun showCloseDialog(position: Int) + + /** + * Notify the browser that the new tab button was clicked. + */ + fun newTabButtonClicked() + + /** + * Notify the browser that the new tab button was long pressed by the user. + */ + fun newTabButtonLongClicked() + + /** + * Notify the browser that the tab close button was clicked for the tab at [position]. + */ + fun tabCloseClicked(position: Int) + + /** + * Notify the browser that the tab at [position] was selected by the user for display. + */ + fun tabClicked(position: Int) + + /** + * Notify the browser that the user pressed the bookmark button. + */ + fun bookmarkButtonClicked() + + /** + * Notify the browser that the user clicked on the bookmark [entry]. + */ + fun bookmarkItemClicked(entry: Bookmark.Entry) + + /** + * Notify the UI that the forward button should be [enabled] for interaction. + */ + fun setForwardButtonEnabled(enabled: Boolean) + + /** + * Notify the UI that the back button should be [enabled] for interaction. + */ + fun setBackButtonEnabled(enabled: Boolean) + + /** + * Notify the UI that the [aTab] should be displayed. + */ + fun onTabChanged(aTab: WebPageTab) + + /** + * Notify the UI that [aTab] started loading a page. + */ + fun onPageStarted(aTab: WebPageTab) + + /** + * + */ + fun onTabChangedUrl(aTab: WebPageTab) + + /** + * + */ + fun onTabChangedIcon(aTab: WebPageTab) + + /** + * + */ + fun onTabChangedTitle(aTab: WebPageTab) + + /** + * Notify the browser that the user pressed the back button. + */ + fun onBackButtonPressed() + + /** + * Notify the browser that the user pressed the forward button. + */ + fun onForwardButtonPressed() + + /** + * Notify the browser that the user pressed the home (not device home) button. + */ + fun onHomeButtonPressed() + + /** + * Notify the browser that the bookmarks list has changed. + */ + fun handleBookmarksChange() + + /** + * Notify the browser that the provided bookmark [bookmark] has changed. + */ + fun handleBookmarkDeleted(bookmark: Bookmark) + + /** + * Notify the browser that the download list has changed. + */ + fun handleDownloadDeleted() + + /** + * Notify the browser that the history list has changed. + */ + fun handleHistoryChange() + + /** + * Notify the controller that a new tab action has originated from a dialog with the [url] and + * the provided [newTabType]. + */ + fun handleNewTab(newTabType: LightningDialogBuilder.NewTab, url: String) + + /** + * + */ + fun executeAction(@IdRes id: Int): Boolean + + /** + * TODO: Defined both in BrowserView and UIController + * Sort out that mess. + */ + fun updateSslState(sslState: SslState) + + /** + * + */ + fun onSingleTapUp(aTab: WebPageTab) + +} diff --git a/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarkUiModel.kt b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarkUiModel.kt new file mode 100644 index 00000000..3b542500 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarkUiModel.kt @@ -0,0 +1,24 @@ +package com.lin.magic.browser.bookmarks + +import com.lin.magic.browser.BookmarksView + +/** + * The UI model representing the current folder shown by the [BookmarksView]. + * + * Created by anthonycr on 5/7/17. + */ +class BookmarkUiModel { + + /** + * Sets the current folder that is being shown. Null represents the root folder. + */ + var currentFolder: String? = null + + /** + * Determines if the current folder is the root folder. + * + * @return true if the current folder is the root, false otherwise. + */ + fun isCurrentFolderRoot(): Boolean = currentFolder == null + +} diff --git a/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarkViewHolder.kt b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarkViewHolder.kt new file mode 100644 index 00000000..8e34785b --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarkViewHolder.kt @@ -0,0 +1,58 @@ +package com.lin.magic.browser.bookmarks + +import com.lin.magic.R +import com.lin.magic.database.Bookmark +import com.lin.magic.utils.ItemDragDropSwipeViewHolder +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView + +class BookmarkViewHolder( + itemView: View, + private val adapter: BookmarksAdapter, + private val iShowBookmarkMenu: (Bookmark) -> Boolean, + private val iOpenBookmark: (Bookmark) -> Unit +) : RecyclerView.ViewHolder(itemView), + ItemDragDropSwipeViewHolder { + + val txtTitle: TextView = itemView.findViewById(R.id.textBookmark) + val favicon: ImageView = itemView.findViewById(R.id.faviconBookmark) + private val iButtonEdit: ImageButton = itemView.findViewById(R.id.button_edit) + private val iCardView: MaterialCardView = itemView.findViewById(R.id.card_view) + + init { + itemView.setOnClickListener{ + val index = adapterPosition + if (index.toLong() != RecyclerView.NO_ID) { + iOpenBookmark(adapter.itemAt(index).bookmark) + } + } + + iButtonEdit.setOnClickListener { + val index = adapterPosition + if (index.toLong() != RecyclerView.NO_ID) { + iShowBookmarkMenu(adapter.itemAt(index).bookmark) + } + } + } + + /** + * Implements [ItemDragDropSwipeViewHolder.onItemOperationStart] + * Start dragging + */ + override fun onItemOperationStart() { + iCardView.isDragged = true + } + + /** + * Implements [ItemDragDropSwipeViewHolder.onItemOperationStop] + * Stop dragging + */ + override fun onItemOperationStop() { + iCardView.isDragged = false + } + +} diff --git a/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksAdapter.kt b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksAdapter.kt new file mode 100644 index 00000000..ef675888 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksAdapter.kt @@ -0,0 +1,185 @@ +package com.lin.magic.browser.bookmarks + +import com.lin.magic.R +import com.lin.magic.browser.WebBrowser +import com.lin.magic.database.Bookmark +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.extensions.drawable +import com.lin.magic.extensions.isDarkTheme +import com.lin.magic.extensions.setImageForTheme +import com.lin.magic.favicon.FaviconModel +import com.lin.magic.utils.ItemDragDropSwipeAdapter +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.Scheduler +import io.reactivex.disposables.Disposable +import io.reactivex.rxkotlin.subscribeBy +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +class BookmarksAdapter( + val context: Context, + val webBrowser: WebBrowser, + private val bookmarksRepository: BookmarkRepository, + private val faviconModel: FaviconModel, + private val networkScheduler: Scheduler, + private val mainScheduler: Scheduler, + private val databaseScheduler: Scheduler, + private val iShowBookmarkMenu: (Bookmark) -> Boolean, + private val iOpenBookmark: (Bookmark) -> Unit +) : RecyclerView.Adapter(), + ItemDragDropSwipeAdapter { + + private var bookmarks: List = listOf() + private val faviconFetchSubscriptions = ConcurrentHashMap() + private val folderIcon = context.drawable(R.drawable.ic_folder) + private val webpageIcon = context.drawable(R.drawable.ic_webpage) + + + fun itemAt(position: Int): BookmarksViewModel = bookmarks[position] + + fun deleteItem(item: BookmarksViewModel) { + val newList = bookmarks - item + updateItems(newList) + } + + /** + * + */ + fun updateItems(newList: List) { + val oldList = bookmarks + bookmarks = newList + + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = bookmarks.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].bookmark.url == bookmarks[newItemPosition].bookmark.url + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == bookmarks[newItemPosition] + }) + + diffResult.dispatchUpdatesTo(this) + } + + fun cleanupSubscriptions() { + for (subscription in faviconFetchSubscriptions.values) { + subscription.dispose() + } + faviconFetchSubscriptions.clear() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkViewHolder { + val inflater = LayoutInflater.from(parent.context) + val itemView = inflater.inflate(R.layout.bookmark_list_item, parent, false) + + return BookmarkViewHolder(itemView, this, iShowBookmarkMenu, iOpenBookmark) + } + + @SuppressLint("RestrictedApi") + override fun onBindViewHolder(holder: BookmarkViewHolder, position: Int) { + holder.itemView.jumpDrawablesToCurrentState() + + val viewModel = bookmarks[position] + holder.txtTitle.text = viewModel.bookmark.title + + val url = viewModel.bookmark.url + holder.favicon.tag = url + + viewModel.icon?.let { + holder.favicon.setImageBitmap(it) + return + } + + val imageDrawable = when (viewModel.bookmark) { + is Bookmark.Folder -> folderIcon + is Bookmark.Entry -> webpageIcon.also { + faviconFetchSubscriptions[url]?.dispose() + faviconFetchSubscriptions[url] = faviconModel + .faviconForUrl(url, viewModel.bookmark.title, context.isDarkTheme()) + .subscribeOn(networkScheduler) + .observeOn(mainScheduler) + .subscribeBy( + onSuccess = { bitmap -> + viewModel.icon = bitmap + if (holder.favicon.tag == url) { + holder.favicon.setImageForTheme(bitmap, context.isDarkTheme()) + } + } + ) + } + } + + holder.favicon.setImageDrawable(imageDrawable) + } + + override fun getItemCount() = bookmarks.size + + /** + * Implements [ItemDragDropSwipeAdapter.onItemMove] + * An item was was moved through drag & drop + */ + override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean + { + val source = bookmarks[fromPosition].bookmark + val destination = bookmarks[toPosition].bookmark + // We can only swap bookmark entries not folders + if (!(source is Bookmark.Entry && destination is Bookmark.Entry)) { + // Folder are shown last in our list for now so we just can't order them + return false + } + + // Swap local list positions + Collections.swap(bookmarks, fromPosition, toPosition) + + // Due to our database definition we need edit position of each bookmarks in current folder + // Go through our list and edit position as needed + var position = 0; + bookmarks.toList().forEach { b -> + if (b.bookmark is Bookmark.Entry) { + if (b.bookmark.position != position || position==fromPosition || position==toPosition) { + val editedItem = Bookmark.Entry( + title = b.bookmark.title, + url = b.bookmark.url, + folder = b.bookmark.folder, + position = position + ) + + position++ + + bookmarksRepository.editBookmark(b.bookmark, editedItem) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler).let { + if (position!=bookmarks.count()){ + it.subscribe() + } else { + // Broadcast update only for our last operation + // Though I have no idea if our operations are FIFO + it.subscribe(webBrowser::handleBookmarksChange) + } + } + } + } + } + + // Tell base class an item was moved + notifyItemMoved(fromPosition, toPosition) + + return true; + } + + /** + * Implements [ItemDragDropSwipeAdapter.onItemDismiss] + */ + override fun onItemDismiss(position: Int) + { + + } +} diff --git a/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksDrawerView.kt b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksDrawerView.kt new file mode 100644 index 00000000..3a48760d --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksDrawerView.kt @@ -0,0 +1,250 @@ +package com.lin.magic.browser.bookmarks + +import com.lin.magic.R +import com.lin.magic.animation.AnimationUtils +import com.lin.magic.browser.BookmarksView +import com.lin.magic.browser.TabsManager +import com.lin.magic.browser.WebBrowser +import com.lin.magic.database.Bookmark +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.databinding.BookmarkDrawerViewBinding +import com.lin.magic.dialog.BrowserDialog +import com.lin.magic.dialog.DialogItem +import com.lin.magic.dialog.LightningDialogBuilder +import com.lin.magic.extensions.drawable +import com.lin.magic.extensions.inflater +import com.lin.magic.favicon.FaviconModel +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.ItemDragDropSwipeHelper +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import com.lin.magic.di.DatabaseScheduler +import com.lin.magic.di.MainScheduler +import com.lin.magic.di.NetworkScheduler +import com.lin.magic.di.configPrefs +import io.reactivex.Scheduler +import io.reactivex.Single +import io.reactivex.disposables.Disposable +import javax.inject.Inject + +/** + * The view that displays bookmarks in a list and some controls. + */ +@AndroidEntryPoint +class BookmarksDrawerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), + BookmarksView { + + @Inject internal lateinit var bookmarkModel: BookmarkRepository + @Inject internal lateinit var bookmarksDialogBuilder: LightningDialogBuilder + @Inject internal lateinit var faviconModel: FaviconModel + @Inject @DatabaseScheduler + internal lateinit var databaseScheduler: Scheduler + @Inject @NetworkScheduler + internal lateinit var networkScheduler: Scheduler + @Inject @MainScheduler + internal lateinit var mainScheduler: Scheduler + @Inject + lateinit var iUserPreferences: UserPreferences + + private val webBrowser: WebBrowser = context as WebBrowser + + // Adapter + private var iAdapter: BookmarksAdapter + // Drag & drop support + private var iItemTouchHelper: ItemTouchHelper? = null + + // Colors + private var scrollIndex: Int = 0 + + private var bookmarksSubscription: Disposable? = null + private var bookmarkUpdateSubscription: Disposable? = null + + private val uiModel = BookmarkUiModel() + var iBinding: BookmarkDrawerViewBinding = BookmarkDrawerViewBinding.inflate(context.inflater,this, true) + + init { + iBinding.uiController = webBrowser + + + iBinding.bookmarkBackButton.setOnClickListener { + if (!uiModel.isCurrentFolderRoot()) { + setBookmarksShown(null, true) + iBinding.listBookmarks.layoutManager?.scrollToPosition(scrollIndex) + } + } + + iAdapter = BookmarksAdapter( + context, + webBrowser, + bookmarkModel, + faviconModel, + networkScheduler, + mainScheduler, + databaseScheduler, + ::showBookmarkMenu, + ::openBookmark + ) + + iBinding.listBookmarks.apply { + // Reverse layout if using bottom tool bars + // LinearLayoutManager.setReverseLayout is also adjusted from BrowserActivity.setupToolBar + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, context.configPrefs.toolbarsBottom) + adapter = iAdapter + } + + // Enable drag & drop but not swipe + val callback: ItemTouchHelper.Callback = ItemDragDropSwipeHelper(iAdapter, true, false) + iItemTouchHelper = ItemTouchHelper(callback) + iItemTouchHelper?.attachToRecyclerView(iBinding.listBookmarks) + + setBookmarksShown(null, true) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + bookmarksSubscription?.dispose() + bookmarkUpdateSubscription?.dispose() + + iAdapter?.cleanupSubscriptions() + } + + private fun getTabsManager(): TabsManager = webBrowser.getTabModel() + + // TODO: apply that logic to the add bookmark menu item from main pop-up menu + // SL: I guess this is of no use here anymore since we removed the add bookmark button + private fun updateBookmarkIndicator(url: String) { + bookmarkUpdateSubscription?.dispose() + bookmarkUpdateSubscription = bookmarkModel.isBookmark(url) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { isBookmark -> + bookmarkUpdateSubscription = null + //addBookmarkView?.isSelected = isBookmark + //addBookmarkView?.isEnabled = !url.isSpecialUrl() + } + } + + override fun handleBookmarkDeleted(bookmark: Bookmark) = when (bookmark) { + is Bookmark.Folder -> setBookmarksShown(null, false) + is Bookmark.Entry -> iAdapter.deleteItem(BookmarksViewModel(bookmark)) ?: Unit + } + + /** + * + */ + private fun setBookmarksShown(folder: String?, animate: Boolean) { + bookmarksSubscription?.dispose() + bookmarksSubscription = bookmarkModel.getBookmarksFromFolderSorted(folder) + .concatWith(Single.defer { + if (folder == null) { + bookmarkModel.getFoldersSorted() + } else { + Single.just(emptyList()) + } + }) + .toList() + .map { it.flatten() } + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { bookmarksAndFolders -> + uiModel.currentFolder = folder + setBookmarkDataSet(bookmarksAndFolders, animate) + iBinding.textTitle.text = if (folder.isNullOrBlank()) resources.getString(R.string.action_bookmarks) else folder + } + } + + /** + * + */ + private fun setBookmarkDataSet(items: List, animate: Boolean) { + iAdapter.updateItems(items.map { BookmarksViewModel(it) }) + val resource = if (uiModel.isCurrentFolderRoot()) { + R.drawable.ic_bookmarks + } else { + R.drawable.ic_action_back + } + + if (animate) { + iBinding.bookmarkBackButton.let { + val transition = AnimationUtils.createRotationTransitionAnimation(it, resource) + it.startAnimation(transition) + } + } else { + iBinding.bookmarkBackButton.setImageResource(resource) + } + } + + /** + * + */ + private fun showBookmarkMenu(bookmark: Bookmark): Boolean { + (context as Activity?)?.let { + when (bookmark) { + is Bookmark.Folder -> bookmarksDialogBuilder.showBookmarkFolderLongPressedDialog(it, webBrowser, bookmark) + is Bookmark.Entry -> bookmarksDialogBuilder.showLongPressedDialogForBookmarkUrl(it, webBrowser, bookmark) + } + } + return true + } + + /** + * + */ + private fun openBookmark(bookmark: Bookmark) = when (bookmark) { + is Bookmark.Folder -> { + scrollIndex = (iBinding.listBookmarks.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + setBookmarksShown(bookmark.title, true) + } + is Bookmark.Entry -> webBrowser.bookmarkItemClicked(bookmark) + } + + + /** + * Show the page tools dialog. + */ + private fun showPageToolsDialog(context: Context) { + val currentTab = getTabsManager().currentTab ?: return + + BrowserDialog.showWithIcons(context, context.getString(R.string.dialog_tools_title), + DialogItem( + icon = context.drawable(R.drawable.ic_action_desktop), + title = R.string.dialog_toggle_desktop + ) { + getTabsManager().currentTab?.apply { + toggleDesktopUserAgent() + reload() + // TODO add back drawer closing + } + }, + ) + } + + override fun navigateBack() { + if (uiModel.isCurrentFolderRoot()) { + webBrowser.onBackButtonPressed() + } else { + setBookmarksShown(null, true) + iBinding.listBookmarks.layoutManager?.scrollToPosition(scrollIndex) + } + } + + override fun handleUpdatedUrl(url: String) { + updateBookmarkIndicator(url) + val folder = uiModel.currentFolder + setBookmarksShown(folder, false) + } + + + +} diff --git a/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksViewModel.kt b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksViewModel.kt new file mode 100644 index 00000000..2f7adddf --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/bookmarks/BookmarksViewModel.kt @@ -0,0 +1,15 @@ +package com.lin.magic.browser.bookmarks + +import com.lin.magic.database.Bookmark +import android.graphics.Bitmap + +/** + * The data model representing a [Bookmark] in a list. + * + * @param bookmark The bookmark backing this view model, either an entry or a folder. + * @param icon The icon for this bookmark. + */ +data class BookmarksViewModel( + val bookmark: Bookmark, + var icon: Bitmap? = null +) diff --git a/app/src/main/java/com/lin/magic/browser/cleanup/BasicIncognitoExitCleanup.kt b/app/src/main/java/com/lin/magic/browser/cleanup/BasicIncognitoExitCleanup.kt new file mode 100644 index 00000000..727b9792 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/cleanup/BasicIncognitoExitCleanup.kt @@ -0,0 +1,19 @@ +package com.lin.magic.browser.cleanup + +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.utils.WebUtils +import android.webkit.WebView +import javax.inject.Inject + +/** + * Exit cleanup that should run on API < 28 when the incognito instance is closed. This is + * significantly less secure than on API > 28 since we can separate WebView data from + */ +class BasicIncognitoExitCleanup @Inject constructor() : + ExitCleanup { + override fun cleanUp(webView: WebView?, context: WebBrowserActivity) { + // We want to make sure incognito mode is secure as possible without also breaking existing + // browser instances. + WebUtils.clearWebStorage() + } +} diff --git a/app/src/main/java/com/lin/magic/browser/cleanup/DelegatingExitCleanup.kt b/app/src/main/java/com/lin/magic/browser/cleanup/DelegatingExitCleanup.kt new file mode 100644 index 00000000..4190ca9f --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/cleanup/DelegatingExitCleanup.kt @@ -0,0 +1,26 @@ +package com.lin.magic.browser.cleanup + +import com.lin.magic.Capabilities +import com.lin.magic.activity.MainActivity +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.isSupported +import android.webkit.WebView +import javax.inject.Inject + +/** + * Exit cleanup that determines which sort of cleanup to do at runtime. It determines which cleanup + * to perform based on the API version and whether we are in incognito mode or normal mode. + */ +class DelegatingExitCleanup @Inject constructor( + private val basicIncognitoExitCleanup: BasicIncognitoExitCleanup, + private val enhancedIncognitoExitCleanup: EnhancedIncognitoExitCleanup, + private val normalExitCleanup: NormalExitCleanup +) : ExitCleanup { + override fun cleanUp(webView: WebView?, context: WebBrowserActivity) { + when { + context is MainActivity -> normalExitCleanup.cleanUp(webView, context) + Capabilities.FULL_INCOGNITO.isSupported -> enhancedIncognitoExitCleanup.cleanUp(webView, context) + else -> basicIncognitoExitCleanup.cleanUp(webView, context) + } + } +} diff --git a/app/src/main/java/com/lin/magic/browser/cleanup/EnhancedIncognitoExitCleanup.kt b/app/src/main/java/com/lin/magic/browser/cleanup/EnhancedIncognitoExitCleanup.kt new file mode 100644 index 00000000..3853dfb2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/cleanup/EnhancedIncognitoExitCleanup.kt @@ -0,0 +1,23 @@ +package com.lin.magic.browser.cleanup + +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.utils.WebUtils +import android.webkit.WebView +import timber.log.Timber +import javax.inject.Inject + +/** + * Exit cleanup that should be run when the incognito process is exited on API >= 28. This cleanup + * clears cookies and all web data, which can be done without affecting + */ +class EnhancedIncognitoExitCleanup @Inject constructor() : + ExitCleanup { + override fun cleanUp(webView: WebView?, context: WebBrowserActivity) { + WebUtils.clearCache(webView, context) + Timber.i("Cache Cleared") + WebUtils.clearCookies() + Timber.i("Cookies Cleared") + WebUtils.clearWebStorage() + Timber.i("WebStorage Cleared") + } +} diff --git a/app/src/main/java/com/lin/magic/browser/cleanup/ExitCleanup.kt b/app/src/main/java/com/lin/magic/browser/cleanup/ExitCleanup.kt new file mode 100644 index 00000000..b3179fcb --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/cleanup/ExitCleanup.kt @@ -0,0 +1,18 @@ +package com.lin.magic.browser.cleanup + +import com.lin.magic.activity.WebBrowserActivity +import android.webkit.WebView + +/** + * A command that runs as the browser instance is shutting down to clean up anything that needs to + * be cleaned up. For instance, if the user has chosen to clear cache on exit or if incognito mode + * is closing. + */ +interface ExitCleanup { + + /** + * Clean up the instance of the browser with the provided [webView] and [context]. + */ + fun cleanUp(webView: WebView?, context: WebBrowserActivity) + +} diff --git a/app/src/main/java/com/lin/magic/browser/cleanup/NormalExitCleanup.kt b/app/src/main/java/com/lin/magic/browser/cleanup/NormalExitCleanup.kt new file mode 100644 index 00000000..66fcfbb2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/cleanup/NormalExitCleanup.kt @@ -0,0 +1,39 @@ +package com.lin.magic.browser.cleanup + +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.database.history.HistoryDatabase +import com.lin.magic.di.DatabaseScheduler +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.WebUtils +import android.webkit.WebView +import io.reactivex.Scheduler +import timber.log.Timber +import javax.inject.Inject + +/** + * Exit cleanup that should run whenever the main browser process is exiting. + */ +class NormalExitCleanup @Inject constructor( + private val userPreferences: UserPreferences, + private val historyDatabase: HistoryDatabase, + @DatabaseScheduler private val databaseScheduler: Scheduler +) : ExitCleanup { + override fun cleanUp(webView: WebView?, context: WebBrowserActivity) { + if (userPreferences.clearCacheExit) { + WebUtils.clearCache(webView, context) + Timber.i("Cache Cleared") + } + if (userPreferences.clearHistoryExitEnabled) { + WebUtils.clearHistory(context, historyDatabase, databaseScheduler) + Timber.i("History Cleared") + } + if (userPreferences.clearCookiesExitEnabled) { + WebUtils.clearCookies() + Timber.i("Cookies Cleared") + } + if (userPreferences.clearWebStorageExitEnabled) { + WebUtils.clearWebStorage() + Timber.i("WebStorage Cleared") + } + } +} diff --git a/app/src/main/java/com/lin/magic/browser/sessions/Session.kt b/app/src/main/java/com/lin/magic/browser/sessions/Session.kt new file mode 100644 index 00000000..4c9b2af6 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/sessions/Session.kt @@ -0,0 +1,52 @@ +//package com.lin.magic.browser.sessions +// We kept this package otherwise it fails to load persisted bundles +// See: https://stackoverflow.com/questions/77292533/load-bundle-from-parcelable-class-after-refactoring +// Could not find an easy solution for it, custom ClassLoader did not work +package acr.browser.lightning.browser.sessions + +import android.os.Parcel +import android.os.Parcelable + +/** + * You can easily regenerate that parcelable implementation. + * See: https://stackoverflow.com/a/49426012/3969362 + * We could also use @Parcelize: https://stackoverflow.com/a/69027267/3969362 + * + * TODO: Don't use Parcelable as it saves the class name in the Bundle and you can't refactor. + * Instead do it like we did with [com.lin.magic.browser.TabModel]. + */ +data class Session ( + var name: String = "", + var tabCount: Int = -1, + var isCurrent: Boolean = false +) : Parcelable { + constructor(parcel: Parcel) : this() { + val n = parcel.readString(); + if (n == null) { + name = "" + } + else { + name = n + } + tabCount = parcel.readInt() + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(name) + parcel.writeInt(tabCount) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Session { + return Session(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/browser/sessions/SessionViewHolder.kt b/app/src/main/java/com/lin/magic/browser/sessions/SessionViewHolder.kt new file mode 100644 index 00000000..231a4f8b --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/sessions/SessionViewHolder.kt @@ -0,0 +1,223 @@ +package com.lin.magic.browser.sessions + +import com.lin.magic.R +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.browser.WebBrowser +import com.lin.magic.dialog.BrowserDialog +import com.lin.magic.extensions.resizeAndShow +import com.lin.magic.extensions.toast +import com.lin.magic.utils.FileNameInputFilter +import com.lin.magic.utils.ItemDragDropSwipeViewHolder +import android.app.Activity +import android.app.Dialog +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable + + +/** + * The [RecyclerView.ViewHolder] for our session list. + * That represents an item in our list, basically one session. + */ +class SessionViewHolder( + view: View, + private val iWebBrowser: WebBrowser +) : RecyclerView.ViewHolder(view), View.OnClickListener, View.OnLongClickListener, + ItemDragDropSwipeViewHolder { + + + // Using view binding won't give us much + val textName: TextView = view.findViewById(R.id.text_name) + val textTabCount: TextView = view.findViewById(R.id.text_tab_count) + val buttonEdit: ImageView = view.findViewById(R.id.button_edit) + val buttonDelete: View = view.findViewById(R.id.button_delete) + val iCardView: MaterialCardView = view.findViewById(R.id.layout_background) + + init { + // Delete a session + buttonDelete.setOnClickListener { + // Just don't delete current session for now + // TODO: implement a solution to indeed delete current session + if (iWebBrowser.getTabModel().iCurrentSessionName == session().name) { + it.context.toast(R.string.session_cant_delete_current) + } else { + MaterialAlertDialogBuilder(it.context) + .setCancelable(true) + .setTitle(R.string.session_prompt_confirm_deletion_title) + .setMessage(it.context.getString(R.string.session_prompt_confirm_deletion_message,session().name)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + // User confirmed deletion, go ahead then + iWebBrowser.getTabModel().deleteSession(textName.tag as String) + // Persist our session list after removing one + iWebBrowser.getTabModel().saveSessions() + // Refresh our list + (it.context as WebBrowserActivity).apply { + iMenuSessions.updateSessions() + } + } + .resizeAndShow() + } + } + + // Edit session name + buttonEdit.setOnClickListener{ + val dialogView = LayoutInflater.from(it.context).inflate(R.layout.dialog_edit_text, null) + val textView = dialogView.findViewById(R.id.dialog_edit_text) + // Make sure user can only enter valid filename characters + textView.filters = arrayOf(FileNameInputFilter()) + + // Init our text field with current name + textView.setText(session().name) + textView.selectAll() + //textView.requestFocus() + + var dialog : Dialog? = null + + //textView.showSoftInputOnFocus + /* + textView.setOnFocusChangeListener{ view, hasFocus -> + if (hasFocus) { + dialog?.window?.setSoftInputMode(SOFT_INPUT_STATE_VISIBLE); + } + } + */ + + dialog = BrowserDialog.showCustomDialog(it.context as Activity) { + setTitle(R.string.session_name_prompt) + setView(dialogView) + setPositiveButton(R.string.action_ok) { _, _ -> + val newName = textView.text.toString() + // Check if session exists already to display proper error message + if (iWebBrowser.getTabModel().isValidSessionName(newName)) { + // Proceed with session rename + iWebBrowser.getTabModel().renameSession(textName.tag as String,newName) + // Make sure we update adapter list and thus edited item too + (iWebBrowser as WebBrowserActivity).iMenuSessions.updateSessions() + } else { + // We already have a session with that name, display an error message + context.toast(R.string.session_already_exists) + } + } + } + + /* + dialog.setOnShowListener { + val imm = dialog.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(textView, InputMethodManager.SHOW_IMPLICIT); + } + + */ + + //TODO: use on show listener? + // TODO: we need to review our dialog APIs + // See: https://stackoverflow.com/a/12997855/3969362 + // Trying to make it so that virtual keyboard opens up as the dialog opens + //val imm = it.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + ////imm.showSoftInput(textView, InputMethodManager.SHOW_FORCED); + //imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) + + } + + // Session item clicked + iCardView.setOnClickListener{ + + if (!iWebBrowser.getTabModel().isInitialized) { + // We are still busy loading a session + it.context.toast(R.string.busy) + return@setOnClickListener + } + + // User wants to switch session + session().name.let { sessionName -> + (it.context as WebBrowserActivity).apply { + tabsManager.switchToSession(sessionName) + if (!isEditModeEnabled()) { + iMenuSessions.dismiss() + } else { + // Update our list, notably current item + iWebBrowser.getTabModel().doOnceAfterInitialization { + iMenuSessions.updateSessions() + iMenuSessions.scrollToCurrentSession() + } + } + } + } + } + + iCardView.setOnLongClickListener(this) + } + + + private fun session() = iWebBrowser.getTabModel().session(textName.tag as String) + + + //TODO: should we have dedicated click handlers instead of a switch? + override fun onClick(v: View) { + if (v === buttonDelete) { + //uiController.tabCloseClicked(adapterPosition) + } else if (v === iCardView) { + } + } + + override fun onLongClick(v: View): Boolean { + //uiController.showCloseDialog(adapterPosition) + //return true + return false + } + + // From ItemTouchHelperViewHolder + // Start dragging + override fun onItemOperationStart() { + iCardView.isDragged = true + } + + // From ItemTouchHelperViewHolder + // Stopped dragging + override fun onItemOperationStop() { + iCardView.isDragged = false + } + + /** + * Tell this view holder to start observing edit mode changes. + */ + fun observeEditMode(observable: Observable): Disposable { + return observable + //.debounce(SEARCH_TYPING_INTERVAL, TimeUnit.MILLISECONDS) + .distinctUntilChanged() + // TODO: Is that needed? Is it not the default somehow? + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { editMode -> + + if (editMode) { + buttonEdit.visibility = View.VISIBLE + buttonDelete.visibility = View.VISIBLE + } else { + buttonEdit.visibility = View.GONE + buttonDelete.visibility = View.GONE + } + + } + } + + /** + * Provide a string representation of our tab count. Return an empty string if tab count is not available. + * Tab count may not be available for recovered sessions for instance. + */ + fun tabCountLabel() = if (session().tabCount>0) session().tabCount.toString() else "" + + /** + * Tell if edit mode is currently enabled + */ + fun isEditModeEnabled() = buttonEdit.visibility == View.VISIBLE + +} diff --git a/app/src/main/java/com/lin/magic/browser/sessions/SessionsAdapter.kt b/app/src/main/java/com/lin/magic/browser/sessions/SessionsAdapter.kt new file mode 100644 index 00000000..c3cd4de3 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/sessions/SessionsAdapter.kt @@ -0,0 +1,138 @@ +package com.lin.magic.browser.sessions + +import acr.browser.lightning.browser.sessions.Session +import com.lin.magic.R +import com.lin.magic.browser.WebBrowser +import com.lin.magic.extensions.inflater +import com.lin.magic.utils.ItemDragDropSwipeAdapter +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.subjects.BehaviorSubject +import java.util.* + +/** + * Sessions [RecyclerView.Adapter]. + * + * TODO: consider using [ListAdapter] instead of [RecyclerView.Adapter] + */ +class SessionsAdapter( + private val webBrowser: WebBrowser +) : RecyclerView.Adapter(), + ItemDragDropSwipeAdapter { + + // Current sessions shown in our dialog + private var iSessions: ArrayList = arrayListOf() + // Collection of disposable subscriptions observing edit mode changes + private var iEditModeSubscriptions: CompositeDisposable = CompositeDisposable() + // Here comes some most certainly overkill way to notify our view holders that our edit mode has changed + // See: https://medium.com/@MiguelSesma/update-recycler-view-content-without-refreshing-the-data-bb79d768bde8 + // See: https://stackoverflow.com/a/49433976/3969362 + var iEditModeEnabledObservable = BehaviorSubject.createDefault(false) + + + /** + * Display the given list of session in our recycler view. + * Possibly updating an existing list. + */ + fun showSessions(aSessions: List) { + DiffUtil.calculateDiff(SessionsDiffCallback(iSessions, aSessions)).dispatchUpdatesTo(this) + iSessions.clear() + // Do a deep copy for our diff to work + // TODO: Surely there must be a way to manage a recycler view without doing a copy of our data set + aSessions.forEach { s -> iSessions.add(Session(s.name,s.tabCount,s.isCurrent)) } + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + // Taking some wild guess here + // Do we need to explicitly call that when adapter is being destroyed? + // I'm guessing not + iEditModeSubscriptions.dispose() + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SessionViewHolder { + val view = viewGroup.context.inflater.inflate(R.layout.session_list_item, viewGroup, false) + return SessionViewHolder(view, webBrowser).apply { + // Ask our newly created view holder to observe our edit mode status + // Thus buttons on our items will be shown or hidden + iEditModeSubscriptions.add(observeEditMode(iEditModeEnabledObservable)) + } + } + + override fun onBindViewHolder(holder: SessionViewHolder, position: Int) { + val session = iSessions[position] + holder.textName.tag = session.name + holder.textName.text = session.name + holder.textTabCount.text = holder.tabCountLabel() + + if (iEditModeEnabledObservable.value == true) { + holder.buttonEdit.visibility = View.VISIBLE + holder.buttonDelete.visibility = View.VISIBLE + } else { + holder.buttonEdit.visibility = View.GONE + holder.buttonDelete.visibility = View.GONE + } + + // Set item font style according to current session + if (session.isCurrent) { + TextViewCompat.setTextAppearance(holder.textName, R.style.boldText) + holder.iCardView.isChecked = true + } else { + TextViewCompat.setTextAppearance(holder.textName, R.style.normalText) + holder.iCardView.isChecked = false + } + } + + override fun getItemCount() = iSessions.size + + // From ItemTouchHelperAdapter + // An item was was moved through drag & drop + override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean + { + // Note: recent tab list is not affected + // Swap local list position + Collections.swap(iSessions, fromPosition, toPosition) + // Swap model list position + Collections.swap(webBrowser.getTabModel().iSessions, fromPosition, toPosition) + // Tell base class an item was moved + notifyItemMoved(fromPosition, toPosition) + // Persist our changes + webBrowser.getTabModel().saveSessions() + return true + } + + // From ItemTouchHelperAdapter + override fun onItemDismiss(position: Int) + { + + } + +} + + +/** + * Diffing callback used to determine whether changes have been made to the list. + * + * @param oldList The old list that is being replaced by the [newList]. + * @param newList The new list replacing the [oldList], which may or may not be different. + */ +class SessionsDiffCallback( + private val oldList: List, + private val newList: List +) : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].name == newList[newItemPosition].name + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition].tabCount == newList[newItemPosition].tabCount && + oldList[oldItemPosition].isCurrent == newList[newItemPosition].isCurrent +} diff --git a/app/src/main/java/com/lin/magic/browser/sessions/SessionsPopupWindow.kt b/app/src/main/java/com/lin/magic/browser/sessions/SessionsPopupWindow.kt new file mode 100644 index 00000000..bb0065e3 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/sessions/SessionsPopupWindow.kt @@ -0,0 +1,296 @@ +package com.lin.magic.browser.sessions + +import acr.browser.lightning.browser.sessions.Session +import com.lin.magic.R +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.browser.WebBrowser +import com.lin.magic.databinding.SessionListBinding +import com.lin.magic.dialog.BrowserDialog +import com.lin.magic.extensions.toast +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.FileNameInputFilter +import com.lin.magic.utils.ItemDragDropSwipeHelper +import com.lin.magic.di.configPrefs +import android.app.Activity +import android.graphics.drawable.ColorDrawable +import android.text.InputFilter +import android.view.* +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.EditText +import android.widget.PopupWindow +import androidx.core.widget.PopupWindowCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.lin.magic.utils.Utils +import javax.inject.Inject + +/** + * TODO: Consider using ListPopupWindow + * TODO: Consider replacing all our PopupWindow with simple floating views. + * In fact PopupWindow seems full of bug, resource hungry and inflexible. + * For instance we can't animate them dynamically because it only uses an animation resource. + */ +class SessionsPopupWindow : PopupWindow { + + var iWebBrowser: WebBrowser + var iAdapter: SessionsAdapter + var iBinding: SessionListBinding + private var iItemTouchHelper: ItemTouchHelper? = null + var iAnchor: View? = null + + @Inject + lateinit var iUserPreferences: UserPreferences + + + constructor(layoutInflater: LayoutInflater, + aBinding: SessionListBinding = SessionListBinding.inflate(layoutInflater)) + : super(aBinding.root, WRAP_CONTENT, WRAP_CONTENT, true) { + + //aBinding.root.context.injector.inject(this) + // Needed to make sure our bottom sheet shows below our session pop-up + PopupWindowCompat.setWindowLayoutType(this, WindowManager.LayoutParams.FIRST_SUB_WINDOW + 5); + + // Elevation just need to be high enough not to cut the effect defined in our layout + elevation = 100F + + iBinding = aBinding + iWebBrowser = aBinding.root.context as WebBrowser + iAdapter = SessionsAdapter(iWebBrowser) + + animationStyle = R.style.AnimationMenu + //animationStyle = android.R.style.Animation_Dialog + + // Needed on Android 5 to make sure our pop-up can be dismissed by tapping outside and back button + // See: https://stackoverflow.com/questions/46872634/close-popupwindow-upon-tapping-outside-or-back-button + setBackgroundDrawable(ColorDrawable()) + + // Handle click on "add session" button + aBinding.buttonNewSession.setOnClickListener { view -> + val dialogView = LayoutInflater.from(aBinding.root.context).inflate(R.layout.dialog_edit_text, null) + val textView = dialogView.findViewById(R.id.dialog_edit_text) + // Make sure user can only enter valid filename characters + textView.filters = arrayOf(FileNameInputFilter()) + + BrowserDialog.showCustomDialog(aBinding.root.context as Activity) { + setTitle(R.string.session_name_prompt) + setView(dialogView) + setPositiveButton(R.string.action_ok) { _, _ -> + val name = textView.text.toString() + // Check if session exists already + if (iWebBrowser.getTabModel().isValidSessionName(name)) { + // That session does not exist yet, add it then + iWebBrowser.getTabModel().iSessions.let { + it.add(Session(name, 1)) + // Switch to our newly added session + (view.context as WebBrowserActivity).apply { + tabsManager.switchToSession(name) + // Close session dialog after creating and switching to new session + if (!isEditModeEnabled()) { + iMenuSessions.dismiss() + } + } + // Update our session list + //iAdapter.showSessions(it) + } + } else { + // We already have a session with that name, display an error message + context.toast(R.string.session_already_exists) + } + } + } + } + + // Handle save as button + // TODO: reuse code between, new, save as and edit dialog + aBinding.buttonSaveSession.setOnClickListener { view -> + val dialogView = LayoutInflater.from(aBinding.root.context).inflate(R.layout.dialog_edit_text, null) + val textView = dialogView.findViewById(R.id.dialog_edit_text) + // Make sure user can only enter valid filename characters + textView.filters = arrayOf(FileNameInputFilter()) + + iWebBrowser.getTabModel().let { tabs -> + BrowserDialog.showCustomDialog(aBinding.root.context as Activity) { + setTitle(R.string.session_name_prompt) + setView(dialogView) + setPositiveButton(R.string.action_ok) { _, _ -> + val name = textView.text.toString() + // Check if session exists already + if (tabs.isValidSessionName(name)) { + // That session does not exist yet, add it then + tabs.iSessions.let { + // Save current session session first + tabs.saveState() + // Add new session + it.add(Session(name, tabs.currentSession().tabCount)) + // Set it as current session + tabs.iCurrentSessionName = name + // Save current tabs that our newly added session + tabs.saveState() + // Switch to our newly added session + (view.context as WebBrowserActivity).apply { + // Close session dialog after creating and switching to new session + if (!isEditModeEnabled()) { + iMenuSessions.dismiss() + } + } + + // Show user we did switch session + view.context.apply { + toast(getString(R.string.session_switched, name)) + } + + // Update our session list + //iAdapter.showSessions(it) + } + } else { + // We already have a session with that name, display an error message + context.toast(R.string.session_already_exists) + } + } + } + } + } + + + aBinding.buttonEditSessions.setOnClickListener { + + // Toggle edit mode + iAdapter.iEditModeEnabledObservable.value?.let { editModeEnabled -> + // Change button icon + // TODO: change the text too? + if (!editModeEnabled) { + aBinding.buttonEditSessions.setImageResource(R.drawable.ic_secured); + } else { + aBinding.buttonEditSessions.setImageResource(R.drawable.ic_edit); + } + // Notify our observers of edit mode change + iAdapter.iEditModeEnabledObservable.onNext(!editModeEnabled) + + // Just close and reopen our menu as our layout change animation is really ugly + dismiss() + iAnchor?.let { + (iWebBrowser as WebBrowserActivity).mainHandler.post { show(it,!editModeEnabled,false) } + } + + // We still broadcast the change above and do a post to avoid getting some items caught not fully animated, even though animations are disabled. + // Android layout animation crap, just don't ask, sometimes it's a blessing other times it's a nightmare... + } + } + + // Make sure Ctrl + Shift + S closes our menu so that toggle is working + // TODO: Somehow still not working + /* + contentView.isFocusableInTouchMode = true + contentView.setOnKeyListener { _, keyCode, event -> + val isCtrlShiftOnly = KeyEvent.metaStateHasModifiers(event.metaState, KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) + //(isCtrlShiftOnly && keyCode == KeyEvent.KEYCODE_S).also { if (it) dismiss() } + if (isCtrlShiftOnly && keyCode == KeyEvent.KEYCODE_S) { + dismiss() + return@setOnKeyListener true + } + return@setOnKeyListener false + } + */ + + // Setup our recycler view + aBinding.recyclerViewSessions.apply { + //setLayerType(View.LAYER_TYPE_NONE, null) + //(itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, context.configPrefs.toolbarsBottom) + adapter = iAdapter + setHasFixedSize(false) + } + + // Enable drag & drop but not swipe + val callback: ItemTouchHelper.Callback = ItemDragDropSwipeHelper(iAdapter, true, false) + iItemTouchHelper = ItemTouchHelper(callback) + iItemTouchHelper?.attachToRecyclerView(iBinding.recyclerViewSessions) + } + + + /** + * + */ + fun show(aAnchor: View, aEdit: Boolean = false, aShowCurrent: Boolean = true) { + // Disable edit mode when showing our menu + iAdapter.iEditModeEnabledObservable.onNext(aEdit) + if (aEdit) { + iBinding.buttonEditSessions.setImageResource(R.drawable.ic_secured); + } else { + iBinding.buttonEditSessions.setImageResource(R.drawable.ic_edit); + } + + iAnchor = aAnchor + //showAsDropDown(aAnchor, 0, 0) + + // Get our anchor location + val anchorLoc = IntArray(2) + aAnchor.getLocationInWindow(anchorLoc) + + + val configPrefs = contentView.context.configPrefs + if (configPrefs.verticalTabBar && !configPrefs.tabBarInDrawer) { + //animationStyle = -1 + //showAsDropDown(iAnchor) + + // + //val gravity = if (configPrefs.toolbarsBottom) Gravity.BOTTOM or Gravity.LEFT else Gravity.TOP or Gravity.LEFT + val gravity = if (configPrefs.toolbarsBottom) Gravity.BOTTOM or Gravity.LEFT else Gravity.TOP or Gravity.LEFT + val xOffset = anchorLoc[0] + val yOffset = if (configPrefs.toolbarsBottom) (contentView.context as WebBrowserActivity).iBinding.root.height - anchorLoc[1] - (aAnchor.height * 0.15).toInt() else anchorLoc[1] + (aAnchor.height * 0.85).toInt() + // Show our popup menu from the right side of the screen below our anchor + showAtLocation(aAnchor, gravity, + // Offset from the left screen edge + xOffset, + // Below our anchor + yOffset) + + } else { + // + val gravity = if (configPrefs.toolbarsBottom) Gravity.BOTTOM or Gravity.RIGHT else Gravity.TOP or Gravity.RIGHT + val yOffset = if (configPrefs.toolbarsBottom) (contentView.context as WebBrowserActivity).iBinding.root.height - anchorLoc[1] else anchorLoc[1] + aAnchor.height + // Show our popup menu from the right side of the screen below our anchor + showAtLocation(aAnchor, gravity, + // Offset from the right screen edge + Utils.dpToPx(10F), + // Below our anchor + yOffset) + } + + //dimBehind() + // Show our sessions + updateSessions() + + if (aShowCurrent) { + // Make sure current session is on the screen + scrollToCurrentSession() + } + } + + /** + * + */ + fun scrollToCurrentSession() { + iBinding.recyclerViewSessions.smoothScrollToPosition(iWebBrowser.getTabModel().currentSessionIndex()) + } + + /** + * + */ + fun updateSessions() { + //See: https://stackoverflow.com/q/43221847/3969362 + // I'm guessing isComputingLayout is not needed anymore since we moved our update after tab manager initialization + // TODO: remove it and switch quickly between sessions to see if that still works + if (!iBinding.recyclerViewSessions.isComputingLayout) { + iAdapter.showSessions(iWebBrowser.getTabModel().iSessions) + } + } + + /** + * Tell if edit mode is currently enabled + */ + private fun isEditModeEnabled() = iAdapter.iEditModeEnabledObservable.value?:false + +} + diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabViewHolder.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabViewHolder.kt new file mode 100644 index 00000000..c0b3ee8d --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabViewHolder.kt @@ -0,0 +1,71 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.R +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.browser.WebBrowser +import com.lin.magic.di.configPrefs +import com.lin.magic.utils.ItemDragDropSwipeViewHolder +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView + +/** + * The [RecyclerView.ViewHolder] for both vertical and horizontal tabs. + * That represents an item in our list, basically one tab. + */ +class TabViewHolder( + view: View, + private val webBrowser: WebBrowser +) : RecyclerView.ViewHolder(view), View.OnClickListener, View.OnLongClickListener, + ItemDragDropSwipeViewHolder { + + // Using view binding won't give us much + val txtTitle: TextView = view.findViewById(R.id.textTab) + val favicon: ImageView = view.findViewById(R.id.faviconTab) + val exitButton: View = view.findViewById(R.id.deleteAction) + val iCardView: MaterialCardView = view.findViewById(R.id.tab_item_background) + // Keep a copy of our tab data to be able to understand what was changed on update + // TODO: Is that how we should do things? + var tab: TabViewState? = null + + init { + exitButton.setOnClickListener(this) + iCardView.setOnClickListener(this) + iCardView.setOnLongClickListener(this) + // Is that the best way to access our preferences? + // If not showing horizontal desktop tab bar, this one always shows close button. + // Apply settings preference for showing close button on tabs. + exitButton.visibility = if (!view.context.configPrefs.verticalTabBar + || (view.context as WebBrowserActivity).userPreferences.showCloseTabButton) View.VISIBLE else View.GONE + } + + override fun onClick(v: View) { + if (v === exitButton) { + webBrowser.tabCloseClicked(adapterPosition) + } else if (v === iCardView) { + webBrowser.tabClicked(adapterPosition) + } + } + + override fun onLongClick(v: View): Boolean { + //uiController.showCloseDialog(adapterPosition) + //return true + return false + } + + // From ItemTouchHelperViewHolder + // Start dragging + override fun onItemOperationStart() { + iCardView.isDragged = true + } + + // From ItemTouchHelperViewHolder + // Stopped dragging + override fun onItemOperationStop() { + iCardView.isDragged = false + } + + +} diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabViewState.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabViewState.kt new file mode 100644 index 00000000..023dc7ed --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabViewState.kt @@ -0,0 +1,48 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.view.WebPageTab +import android.graphics.Bitmap +import android.graphics.Color +import timber.log.Timber + +/** + * @param id The unique id of the tab. + * @param title The title of the tab. + * @param favicon The favicon of the tab. + * @param isForeground True if the tab is in the foreground, false otherwise. + */ +data class TabViewState( + val id: Int = 0, + val title: String = "", + val favicon: Bitmap = createDefaultBitmap(), + val isForeground: Boolean = false, + val themeColor: Int = Color.TRANSPARENT, + val isFrozen: Boolean = true +) { + init { + // TODO: This is called way too many times from displayTabs() through asTabViewState + // Find a way to improve this + //Timber.v("init") + } +} + +/** + * We used a function to be able to log + */ +private fun createDefaultBitmap() : Bitmap { + Timber.w("createDefaultBitmap - ideally that should never be called") + return Bitmap.createBitmap(1,1,Bitmap.Config.ARGB_8888) +} + + +/** + * Converts a [WebPageTab] to a [TabViewState]. + */ +fun WebPageTab.asTabViewState() = TabViewState( + id = id, + title = title, + favicon = favicon, + isForeground = isForeground, + themeColor = htmlMetaThemeColor, + isFrozen = isFrozen +) diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabViewStateDiffCallback.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabViewStateDiffCallback.kt new file mode 100644 index 00000000..615702e5 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabViewStateDiffCallback.kt @@ -0,0 +1,24 @@ +package com.lin.magic.browser.tabs + +import androidx.recyclerview.widget.DiffUtil + +/** + * Diffing callback used to determine whether changes have been made to the list. + * + * @param oldList The old list that is being replaced by the [newList]. + * @param newList The new list replacing the [oldList], which may or may not be different. + */ +class TabViewStateDiffCallback( + private val oldList: List, + private val newList: List +) : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].id == newList[newItemPosition].id + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition] == newList[newItemPosition] +} diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabsAdapter.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabsAdapter.kt new file mode 100644 index 00000000..caaf50a4 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabsAdapter.kt @@ -0,0 +1,69 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.browser.WebBrowser +import com.lin.magic.utils.ItemDragDropSwipeAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import java.util.* + +/** + * Abstract base tabs adapter. + * Implement functionality common to our concrete tabs adapters. + */ +abstract class TabsAdapter(val webBrowser: WebBrowser): RecyclerView.Adapter(), + ItemDragDropSwipeAdapter { + + protected var tabList: List = emptyList() + + /** + * Show tabs and compute diffs. + * TODO: Though I wonder how that works without copying the list which we had to do in our SessionsAdapter. + */ + fun showTabs(tabs: List) { + val oldList = tabList + tabList = tabs + DiffUtil.calculateDiff(TabViewStateDiffCallback(oldList, tabList)).dispatchUpdatesTo(this) + } + + /** + * From [RecyclerView.Adapter] + */ + override fun getItemCount() = tabList.size + + /** + * From [RecyclerView.Adapter] + */ + override fun onViewRecycled(holder: TabViewHolder) { + super.onViewRecycled(holder) + // I'm not convinced that's needed + //(uiController as BrowserActivity).toast("Recycled: " + holder.tab.title) + holder.tab = null + } + + /** + * From [ItemDragDropSwipeAdapter] + * An item was was moved through drag & drop + */ + override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean + { + // Note: recent tab list is not affected + // Swap local list position + Collections.swap(tabList, fromPosition, toPosition) + // Swap model list position + Collections.swap(webBrowser.getTabModel().allTabs, fromPosition, toPosition) + // Tell base class an item was moved + notifyItemMoved(fromPosition, toPosition) + return true + } + + /** + * From [ItemDragDropSwipeAdapter] + * An item was was dismissed through swipe + */ + override fun onItemDismiss(position: Int) + { + webBrowser.tabCloseClicked(position) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabsDesktopAdapter.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabsDesktopAdapter.kt new file mode 100644 index 00000000..52e89814 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabsDesktopAdapter.kt @@ -0,0 +1,145 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.R +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.browser.WebBrowser +import com.lin.magic.utils.ItemDragDropSwipeAdapter +import com.lin.magic.utils.ThemeUtils +import com.lin.magic.view.BackgroundDrawable +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Resources +import android.graphics.Color +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.graphics.ColorUtils +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.lin.magic.extensions.inflater +import com.lin.magic.extensions.isDarkTheme +import com.lin.magic.extensions.setImageForTheme + +/** + * The adapter for horizontal desktop style browser tabs. + */ +class TabsDesktopAdapter( + context: Context, + private val resources: Resources, + webBrowser: WebBrowser +) : TabsAdapter(webBrowser), + ItemDragDropSwipeAdapter { + + + private var textColor = Color.TRANSPARENT + private var foregroundTabColor: Int = Color.TRANSPARENT + + init { + //val backgroundColor = Utils.mixTwoColors(ThemeUtils.getPrimaryColor(context), Color.BLACK, 0.75f) + //val foregroundColor = ThemeUtils.getPrimaryColor(context) + } + + /** + * From [RecyclerView.Adapter] + */ + override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): TabViewHolder { + val view = viewGroup.context.inflater.inflate(R.layout.tab_list_item_horizontal, viewGroup, false) + view.background = BackgroundDrawable(view.context) + //val tab = tabList[i] + return TabViewHolder(view, webBrowser) + } + + /** + * From [RecyclerView.Adapter] + */ + override fun onBindViewHolder(holder: TabViewHolder, position: Int) { + holder.exitButton.tag = position + + val tab = tabList[position] + + holder.txtTitle.text = tab.title + updateViewHolderAppearance(holder, tab) + updateViewHolderFavicon(holder, tab) + // Update our copy so that we can check for changes then + holder.tab = tab.copy(); + } + + /** + * + */ + private fun updateViewHolderFavicon(viewHolder: TabViewHolder, tab: TabViewState) { + // Apply filter to favicon if needed + val ba = webBrowser as WebBrowserActivity + if (tab.isForeground) { + // Make sure that on light theme with dark tab background because color mode we still inverse favicon color if needed, see github.com + viewHolder.favicon.setImageForTheme(tab.favicon, ColorUtils.calculateLuminance(foregroundTabColor)<0.2) + } + else { + viewHolder.favicon.setImageForTheme(tab.favicon, ba.isDarkTheme()) + } + } + + /** + * + */ + private fun updateViewHolderAppearance(viewHolder: TabViewHolder, tab: TabViewState) { + + // Just to init our default text color + if (textColor == Color.TRANSPARENT) { + textColor = viewHolder.txtTitle.currentTextColor + } + + if (tab.isForeground) { + TextViewCompat.setTextAppearance(viewHolder.txtTitle, R.style.boldText) + val newTextColor = (webBrowser as WebBrowserActivity).currentToolBarTextColor + viewHolder.txtTitle.setTextColor(newTextColor) + viewHolder.exitButton.findViewById(R.id.deleteButton).setColorFilter(newTextColor) + // If we just got to the foreground + if (tab.isForeground!=viewHolder.tab?.isForeground + // or if our theme color changed + || tab.themeColor!=viewHolder.tab?.themeColor + // or if our theme color is different than our UI color, i.e. using favicon color instead of meta theme + || tab.themeColor!=webBrowser.getUiColor()) { + + val backgroundColor = ThemeUtils.getColor(viewHolder.iCardView.context, R.attr.colorSurface) + + // Pick our color according to settings and states + foregroundTabColor = if (webBrowser.isColorMode()) + if (tab.themeColor!=Color.TRANSPARENT) + // Use meta theme color if we have one + tab.themeColor + else + if (webBrowser.getUiColor()!=backgroundColor) + // Use favicon extracted color if there is one + webBrowser.getUiColor() + else + // Otherwise use default theme color + backgroundColor + else // No color mode just use our theme default background then + backgroundColor + + // Apply proper color then + viewHolder.iCardView.backgroundTintList = ColorStateList.valueOf(foregroundTabColor) + + if (foregroundTabColor==backgroundColor) { + // Make sure we can tell which tab is the current one when not using color mode + viewHolder.iCardView.isCheckable = true + viewHolder.iCardView.isChecked = true + } else { + viewHolder.iCardView.isChecked = false + viewHolder.iCardView.isCheckable = false + } + + } + } + else { + // Reset background color, we did not have to make a backup of it since it's null anyway + viewHolder.iCardView.backgroundTintList = null + viewHolder.iCardView.isChecked = false + viewHolder.iCardView.isCheckable = false + // Background tab + TextViewCompat.setTextAppearance(viewHolder.txtTitle, if (tab.isFrozen) R.style.italicText else R.style.normalText) + viewHolder.txtTitle.setTextColor(textColor) + viewHolder.exitButton.findViewById(R.id.deleteButton).setColorFilter(textColor) + } + } +} diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabsDesktopView.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabsDesktopView.kt new file mode 100644 index 00000000..bd1c0a1e --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabsDesktopView.kt @@ -0,0 +1,117 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.R +import com.lin.magic.browser.TabsView +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.browser.WebBrowser +import com.lin.magic.databinding.TabDesktopViewBinding +import com.lin.magic.extensions.inflater +import com.lin.magic.utils.ItemDragDropSwipeHelper +import com.lin.magic.view.WebPageTab +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import timber.log.Timber +import java.lang.Exception + +/** + * A view which displays browser tabs in a horizontal [RecyclerView]. + * TODO: Rename to horizontal? + */ +class TabsDesktopView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), + TabsView { + + private val webBrowser = context as WebBrowser + private val tabsAdapter: TabsDesktopAdapter + private val tabList: RecyclerView + private var iItemTouchHelper: ItemTouchHelper? = null + // Inflate our layout with binding support + val iBinding: TabDesktopViewBinding = TabDesktopViewBinding.inflate(context.inflater,this, true) + + init { + // Provide UI controller + iBinding.uiController = webBrowser + + val layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + + + tabsAdapter = TabsDesktopAdapter(context, context.resources, webBrowser) + + tabList = findViewById(R.id.tabs_list).apply { + setLayerType(View.LAYER_TYPE_NONE, null) + // We don't want that morphing animation for now + (itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false + this.layoutManager = layoutManager + adapter = tabsAdapter + setHasFixedSize(true) + } + + // Enable drag & drop but not swipe + val callback: ItemTouchHelper.Callback = ItemDragDropSwipeHelper(tabsAdapter, true, false, ItemTouchHelper.END or ItemTouchHelper.START) + iItemTouchHelper = ItemTouchHelper(callback) + iItemTouchHelper?.attachToRecyclerView(iBinding.tabsList) + } + + /** + * Enable tool bar buttons according to current state of things + * TODO: Find a way to share that code with TabsDrawerView + */ + private fun updateTabActionButtons() { + // If more than one tab, enable close all tabs button + iBinding.actionCloseAllTabs.isEnabled = webBrowser.getTabModel().allTabs.count()>1 + // If we have more than one tab in our closed tabs list enable restore all pages button + iBinding.actionRestoreAllPages.isEnabled = ((webBrowser as WebBrowserActivity).tabsManager.closedTabs.bundleStack.count() ?: 0) > 1 + // If we have at least one tab in our closed tabs list enable restore page button + iBinding.actionRestorePage.isEnabled = ((webBrowser as WebBrowserActivity).tabsManager.closedTabs.bundleStack.count() ?: 0) > 0 + // No sessions in incognito mode + if (webBrowser.isIncognito()) { + iBinding.actionSessions.visibility = View.GONE + } + } + + + override fun tabAdded() { + displayTabs() + updateTabActionButtons() + } + + override fun tabRemoved(position: Int) { + displayTabs() + updateTabActionButtons() + } + + override fun tabChanged(position: Int) { + displayTabs() + // Needed for the foreground tab color to update. + // However sometimes it throws an illegal state exception so make sure we catch it. + try { + tabsAdapter.notifyItemChanged(position) + } catch (e: Exception) { + Timber.e(e,"notifyItemChanged") + } + + } + + private fun displayTabs() { + tabsAdapter.showTabs(webBrowser.getTabModel().allTabs.map(WebPageTab::asTabViewState)) + } + + override fun tabsInitialized() { + tabsAdapter.notifyDataSetChanged() + updateTabActionButtons() + } + + override fun setGoBackEnabled(isEnabled: Boolean) = Unit + + override fun setGoForwardEnabled(isEnabled: Boolean) = Unit + +} diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabsDrawerAdapter.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabsDrawerAdapter.kt new file mode 100644 index 00000000..0334c282 --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabsDrawerAdapter.kt @@ -0,0 +1,77 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.R +import com.lin.magic.browser.WebBrowser +import com.lin.magic.extensions.dimen +import com.lin.magic.extensions.inflater +import com.lin.magic.extensions.isDarkTheme +import com.lin.magic.extensions.setImageForTheme +import android.content.Context +import android.graphics.Bitmap +import android.view.ViewGroup +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import timber.log.Timber + +/** + * The adapter for vertical mobile style browser tabs. + */ +class TabsDrawerAdapter( + webBrowser: WebBrowser +) : TabsAdapter(webBrowser) { + + /** + * From [RecyclerView.Adapter] + */ + override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): TabViewHolder { + val view = viewGroup.context.inflater.inflate(R.layout.tab_list_item, viewGroup, false) + return TabViewHolder(view, webBrowser) //.apply { setIsRecyclable(false) } + } + + /** + * From [RecyclerView.Adapter] + */ + override fun onBindViewHolder(holder: TabViewHolder, position: Int) { + holder.exitButton.tag = position + + val tab = tabList[position] + + holder.txtTitle.text = tab.title + updateViewHolderAppearance(holder, tab) + updateViewHolderFavicon(holder, tab.favicon, tab.isForeground) + updateViewHolderBackground(holder, tab.isForeground) + // Update our copy so that we can check for changes then + holder.tab = tab.copy(); + } + + private fun updateViewHolderFavicon(viewHolder: TabViewHolder, favicon: Bitmap, isForeground: Boolean) { + // Apply filter to favicon if needed + viewHolder.favicon.setImageForTheme(favicon, (webBrowser as Context).isDarkTheme()) + } + + private fun updateViewHolderBackground(viewHolder: TabViewHolder, isForeground: Boolean) { + + Timber.d("updateViewHolderBackground: $isForeground - ${viewHolder.txtTitle.text}") + viewHolder.iCardView.apply { + isChecked = isForeground + // Adjust tab item height depending of foreground state + val params = layoutParams + params.height = context.dimen(if (isForeground) R.dimen.material_grid_touch_xxlarge else R.dimen.material_grid_touch_large) + layoutParams = params + } + + } + + private fun updateViewHolderAppearance(viewHolder: TabViewHolder, tab: TabViewState) { + if (tab.isForeground) { + TextViewCompat.setTextAppearance(viewHolder.txtTitle, R.style.boldText) + webBrowser.changeToolbarBackground(tab.favicon, tab.themeColor, null) + } else if (tab.isFrozen) { + TextViewCompat.setTextAppearance(viewHolder.txtTitle, R.style.italicText) + } + else { + TextViewCompat.setTextAppearance(viewHolder.txtTitle, R.style.normalText) + } + } + +} diff --git a/app/src/main/java/com/lin/magic/browser/tabs/TabsDrawerView.kt b/app/src/main/java/com/lin/magic/browser/tabs/TabsDrawerView.kt new file mode 100644 index 00000000..8e6bcb8e --- /dev/null +++ b/app/src/main/java/com/lin/magic/browser/tabs/TabsDrawerView.kt @@ -0,0 +1,139 @@ +package com.lin.magic.browser.tabs + +import com.lin.magic.browser.TabsView +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.browser.WebBrowser +import com.lin.magic.databinding.TabDrawerViewBinding +import com.lin.magic.di.configPrefs +import com.lin.magic.extensions.inflater +import com.lin.magic.utils.ItemDragDropSwipeHelper +import com.lin.magic.utils.fixScrollBug +import com.lin.magic.view.WebPageTab +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + + +/** + * A view which displays tabs in a vertical [RecyclerView]. + */ +class TabsDrawerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), + TabsView { + + private val webBrowser = context as WebBrowser + private val tabsAdapter: TabsDrawerAdapter + + private var mItemTouchHelper: ItemTouchHelper? = null + + var iBinding: TabDrawerViewBinding + + init { + + //context.injector.inject(this) + + orientation = VERTICAL + isClickable = true + isFocusable = true + + // Inflate our layout with binding support + iBinding = TabDrawerViewBinding.inflate(context.inflater,this, true) + // Provide UI controller for data binding to work + iBinding.uiController = webBrowser + + tabsAdapter = TabsDrawerAdapter(webBrowser) + + iBinding.tabsList.apply { + //setLayerType(View.LAYER_TYPE_NONE, null) + // We don't want that morphing animation for now + (itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false + // Reverse layout if using bottom tool bars + // LinearLayoutManager.setReverseLayout is also adjusted from BrowserActivity.setupToolBar + val lm = LinearLayoutManager(context, RecyclerView.VERTICAL, context.configPrefs.toolbarsBottom) + // Though that should not be needed as it is taken care of by [fixScrollBug] + // See: https://github.com/Slion/Magic/issues/212 + lm.stackFromEnd = context.configPrefs.toolbarsBottom + layoutManager = lm + adapter = tabsAdapter + // That would prevent our recycler to resize as needed with bottom sheets + setHasFixedSize(false) + } + + + + val callback: ItemTouchHelper.Callback = ItemDragDropSwipeHelper(tabsAdapter) + + mItemTouchHelper = ItemTouchHelper(callback) + mItemTouchHelper?.attachToRecyclerView(iBinding.tabsList) + } + + /** + * Enable tool bar buttons according to current state of things + * TODO: Find a way to share that code with TabsDesktopView + */ + private fun updateTabActionButtons() { + // If more than one tab, enable close all tabs button + iBinding.actionCloseAllTabs.isEnabled = webBrowser.getTabModel().allTabs.count()>1 + // If we have more than one tab in our closed tabs list enable restore all pages button + iBinding.actionRestoreAllPages.isEnabled = ((webBrowser as WebBrowserActivity).tabsManager.closedTabs.bundleStack.count() ?: 0) > 1 + // If we have at least one tab in our closed tabs list enable restore page button + iBinding.actionRestorePage.isEnabled = ((webBrowser as WebBrowserActivity).tabsManager.closedTabs.bundleStack.count() ?: 0) > 0 + // No sessions in incognito mode + if (webBrowser.isIncognito()) { + iBinding.actionSessions.visibility = View.GONE + } + + } + + override fun tabAdded() { + displayTabs() + updateTabActionButtons() + } + + override fun tabRemoved(position: Int) { + displayTabs() + //tabsAdapter.notifyItemRemoved(position) + updateTabActionButtons() + } + + override fun tabChanged(position: Int) { + displayTabs() + //tabsAdapter.notifyItemChanged(position) + } + + /** + * TODO: this is called way to often for my taste and should be optimized somehow. + */ + private fun displayTabs() { + tabsAdapter.showTabs(webBrowser.getTabModel().allTabs.map(WebPageTab::asTabViewState)) + + if (fixScrollBug(iBinding.tabsList)) { + // Scroll bug was fixed trigger a scroll to current item then + (context as WebBrowserActivity).apply { + mainHandler.postDelayed({ tryScrollToCurrentTab() }, 0) + } + } + } + + override fun tabsInitialized() { + tabsAdapter.notifyDataSetChanged() + updateTabActionButtons() + } + + override fun setGoBackEnabled(isEnabled: Boolean) { + //actionBack.isEnabled = isEnabled + } + + override fun setGoForwardEnabled(isEnabled: Boolean) { + //actionForward.isEnabled = isEnabled + } + +} diff --git a/app/src/main/java/com/lin/magic/constant/Constants.kt b/app/src/main/java/com/lin/magic/constant/Constants.kt new file mode 100644 index 00000000..88ea5acf --- /dev/null +++ b/app/src/main/java/com/lin/magic/constant/Constants.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2014 A.C.R. Development + */ +@file:JvmName("Constants") + +package com.lin.magic.constant + +// Hardcoded user agents +const val WINDOWS_DESKTOP_USER_AGENT_PREFIX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" +const val LINUX_DESKTOP_USER_AGENT = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0" +const val MACOS_DESKTOP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15" +const val ANDROID_MOBILE_USER_AGENT_PREFIX = "Mozilla/5.0 (Linux; Android 11; Pixel 5 Build/RQ1A.210205.004; wv)" +const val IOS_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1" + +// URL Schemes +const val HTTP = "http://" +const val HTTPS = "https://" +const val FILE = "file://" +const val FOLDER = "folder://" + +object Schemes { + const val Magic = "magic" + const val About = "about" +} + +object Hosts { + const val Home = "home" + const val Start = "start" + const val Incognito = "incognito" + const val Bookmarks = "bookmarks" + const val History = "history" + const val Downloads = "downloads" + const val Noop = "noop" + const val Blank = "blank" +} + +object Uris { + const val MagicHome = "${Schemes.Magic}://${Hosts.Home}" + const val MagicStart = "${Schemes.Magic}://${Hosts.Start}" + const val MagicIncognito = "${Schemes.Magic}://${Hosts.Incognito}" + const val MagicBookmarks = "${Schemes.Magic}://${Hosts.Bookmarks}" + const val MagicDownloads = "${Schemes.Magic}://${Hosts.Downloads}" + const val MagicHistory = "${Schemes.Magic}://${Hosts.History}" + const val MagicNoop = "${Schemes.Magic}://${Hosts.Noop}" + // Custom local page schemes + const val AboutHome = "${Schemes.About}:${Hosts.Home}" + const val AboutIncognito = "${Schemes.About}:${Hosts.Incognito}" + const val AboutBlank = "${Schemes.About}:${Hosts.Blank}" + const val AboutBookmarks = "${Schemes.About}:${Hosts.Bookmarks}" + const val AboutHistory = "${Schemes.About}:${Hosts.History}" +} + +/** + * Not sure why we needed those here now. + * Could have been needed for our configuration preferences architecture. + */ +object PrefKeys { + const val HideStatusBar = "pref_key_hide_status_bar" + const val HideToolBar = "pref_key_hide_tool_bar" + const val ShowToolBarWhenScrollUp = "pref_key_show_tool_bar_on_scroll_up" + const val ShowToolBarOnPageTop = "pref_key_show_tool_bar_on_page_top" + const val DesktopWidth = "pref_key_desktop_width_float" + const val PullToRefresh = "pref_key_pull_to_refresh" + const val TabBarVertical = "pref_key_tab_bar_vertical" + const val TabBarInDrawer = "pref_key_tab_bar_in_drawer" + const val ToolbarsBottom = "pref_key_toolbars_bottom" +} + + +const val UTF8 = "UTF-8" + +// Default text encoding we will use +const val DEFAULT_ENCODING = UTF8 + +// Allowable text encodings for the WebView +@JvmField +val TEXT_ENCODINGS = arrayOf( + UTF8, "Big5", "Big5-HKSCS", "CESU-8", "EUC-JP", "EUC-KR", "GB18030", "GB2312", "GBK", "IBM-Thai", "IBM00858", "IBM01140", "IBM01141", "IBM01142", "IBM01143", "IBM01144", "IBM01145", "IBM01146", "IBM01147", "IBM01148", "IBM01149", "IBM037", "IBM1026", "IBM1047", "IBM273", "IBM277", "IBM278", "IBM280", "IBM284", "IBM285", "IBM290", "IBM297", "IBM420", "IBM424", "IBM437", "IBM500", "IBM775", "IBM850", "IBM852", "IBM855", "IBM857", "IBM860", "IBM861", "IBM862", "IBM863", "IBM864", "IBM865", "IBM866", "IBM868", "IBM869", "IBM870", "IBM871", "IBM918", "ISO-2022-CN", "ISO-2022-JP", "ISO-2022-JP-2", "ISO-2022-KR", "ISO-8859-1", "ISO-8859-13", "ISO-8859-15", "ISO-8859-2", "ISO-8859-3", "ISO-8859-4", "ISO-8859-5", "ISO-8859-6", "ISO-8859-7", "ISO-8859-8", "ISO-8859-9", "JIS_X0201", "JIS_X0212-1990", "KOI8-R", "KOI8-U", "Shift_JIS", "TIS-620", "US-ASCII", "UTF-16", "UTF-16BE", "UTF-16LE", "UTF-32", "UTF-32BE", "UTF-32LE", "windows-1250", "windows-1251", "windows-1252", "windows-1253", "windows-1254", "windows-1255", "windows-1256", "windows-1257", "windows-1258", "windows-31j", "x-Big5-HKSCS-2001", "x-Big5-Solaris", "x-COMPOUND_TEXT", "x-euc-jp-linux", "x-EUC-TW", "x-eucJP-Open", "x-IBM1006", "x-IBM1025", "x-IBM1046", "x-IBM1097", "x-IBM1098", "x-IBM1112", "x-IBM1122", "x-IBM1123", "x-IBM1124", "x-IBM1166", "x-IBM1364", "x-IBM1381", "x-IBM1383", "x-IBM300", "x-IBM33722", "x-IBM737", "x-IBM833", "x-IBM834", "x-IBM856", "x-IBM874", "x-IBM875", "x-IBM921", "x-IBM922", "x-IBM930", "x-IBM933", "x-IBM935", "x-IBM937", "x-IBM939", "x-IBM942", "x-IBM942C", "x-IBM943", "x-IBM943C", "x-IBM948", "x-IBM949", "x-IBM949C", "x-IBM950", "x-IBM964", "x-IBM970", "x-ISCII91", "x-ISO-2022-CN-CNS", "x-ISO-2022-CN-GB", "x-iso-8859-11", "x-JIS0208", "x-JISAutoDetect", "x-Johab", "x-MacArabic", "x-MacCentralEurope", "x-MacCroatian", "x-MacCyrillic", "x-MacDingbat", "x-MacGreek", "x-MacHebrew", "x-MacIceland", "x-MacRoman", "x-MacRomania", "x-MacSymbol", "x-MacThai", "x-MacTurkish", "x-MacUkraine", "x-MS932_0213", "x-MS950-HKSCS", "x-MS950-HKSCS-XP", "x-mswin-936", "x-PCK", "x-SJIS_0213", "x-UTF-16LE-BOM", "X-UTF-32BE-BOM", "X-UTF-32LE-BOM", "x-windows-50220", "x-windows-50221", "x-windows-874", "x-windows-949", "x-windows-950", "x-windows-iso2022jp") + +const val INTENT_ORIGIN = "URL_INTENT_ORIGIN" diff --git a/app/src/main/java/com/lin/magic/database/DatabaseDelegate.kt b/app/src/main/java/com/lin/magic/database/DatabaseDelegate.kt new file mode 100644 index 00000000..b882d0cd --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/DatabaseDelegate.kt @@ -0,0 +1,27 @@ +package com.lin.magic.database + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * A delegate that caches a [SQLiteDatabase] object for the consumer, reopening it whenever it is + * provided if it has been closed between the last time it was accessed. + */ +private class DatabaseDelegate : ReadOnlyProperty { + + private var sqLiteDatabase: SQLiteDatabase? = null + + override fun getValue(thisRef: SQLiteOpenHelper, property: KProperty<*>): SQLiteDatabase { + return sqLiteDatabase?.takeIf(SQLiteDatabase::isOpen) + ?: thisRef.writableDatabase.also { sqLiteDatabase = it } + } + +} + +/** + * Provides a delegate that caches a [SQLiteDatabase] object for the consumer, reopening it if it + * has been closed. + */ +fun databaseDelegate(): ReadOnlyProperty = DatabaseDelegate() diff --git a/app/src/main/java/com/lin/magic/database/WebPage.kt b/app/src/main/java/com/lin/magic/database/WebPage.kt new file mode 100644 index 00000000..f1f4f8f9 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/WebPage.kt @@ -0,0 +1,92 @@ +package com.lin.magic.database + +import com.lin.magic.constant.FOLDER + +/** + * A data type that represents a page that can be loaded. + * + * @param url The URL of the web page. + * @param title The title of the web page. + */ +sealed class WebPage( + open val url: String, + open val title: String +) + +/** + * A data type that represents a page that was visited by the user. + * + * @param lastTimeVisited The last time the page was visited in milliseconds. + */ +data class HistoryEntry( + override val url: String, + override val title: String, + val lastTimeVisited: Long = System.currentTimeMillis() +) : WebPage(url, title) + +/** + * A data type that represents an entity that has been bookmarked by the user or contains a page + * that has been bookmarked by the user. + */ +sealed class Bookmark( + override val url: String, + override val title: String +) : WebPage(url, title) { + + /** + * A data type that has been bookmarked by the user. + * + * @param position The position of the bookmark in its folder. + * @param folder The folder in which the bookmark resides. + */ + data class Entry( + override val url: String, + override val title: String, + val position: Int, + val folder: Folder + ) : Bookmark(url, title) + + /** + * A data type that represents a container for a [Bookmark.Entry]. + */ + sealed class Folder( + override val url: String, + override val title: String + ) : Bookmark(url, title) { + + /** + * The root folder that contains bookmarks and other folders. + */ + object Root : Folder("", "") + + /** + * A folder that contains bookmarks. + */ + data class Entry( + override val url: String, + override val title: String + ) : Folder(url, title) + + } + +} + +/** + * A data type that represents a suggestion for a search query. + */ +data class SearchSuggestion( + override val url: String, + override val title: String +) : WebPage(url, title) + +/** + * Creates a [Bookmark.Folder] from the provided [String]. + */ +fun String?.asFolder(): Bookmark.Folder = this + ?.takeIf(String::isNotBlank) + ?.let { + Bookmark.Folder.Entry( + url = "$FOLDER$this", + title = this + ) + } ?: Bookmark.Folder.Root diff --git a/app/src/main/java/com/lin/magic/database/adblock/Host.kt b/app/src/main/java/com/lin/magic/database/adblock/Host.kt new file mode 100644 index 00000000..ba3f99f3 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/adblock/Host.kt @@ -0,0 +1,8 @@ +package com.lin.magic.database.adblock + +/** + * Representation of a host. + * + * @param name The name of the host. + */ +inline class Host(val name: String) diff --git a/app/src/main/java/com/lin/magic/database/adblock/UserRulesDatabase.kt b/app/src/main/java/com/lin/magic/database/adblock/UserRulesDatabase.kt new file mode 100644 index 00000000..9bea3759 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/adblock/UserRulesDatabase.kt @@ -0,0 +1,239 @@ +package com.lin.magic.database.adblock + +import com.lin.magic.adblock.UnifiedFilterResponse +import com.lin.magic.database.adblock.UserRulesRepository.Companion.RESPONSE_BLOCK +import com.lin.magic.database.adblock.UserRulesRepository.Companion.RESPONSE_EXCLUSION +import com.lin.magic.database.adblock.UserRulesRepository.Companion.RESPONSE_NOOP +import com.lin.magic.database.databaseDelegate +import android.app.Application +import android.content.ContentValues +import android.database.Cursor +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import jp.hazuki.yuzubrowser.adblock.filter.unified.* +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class UserRulesDatabase @Inject constructor( + application: Application +) : SQLiteOpenHelper(application, DATABASE_NAME, null, DATABASE_VERSION), + UserRulesRepository { + + private val database: SQLiteDatabase by databaseDelegate() + + + // TODO: simplify? actually tag is always the same as pattern in the user rules... + + // Creating Tables + override fun onCreate(db: SQLiteDatabase) { + // create only one table, not necessary to have many for different lists, or exclusions + val createRulesTable = "CREATE TABLE ${DatabaseUtils.sqlEscapeString(TABLE_RULES)}(" + + "${DatabaseUtils.sqlEscapeString(KEY_ID)} INTEGER PRIMARY KEY autoincrement," + + // filter info: list/entity, tag, exclusion + //"${DatabaseUtils.sqlEscapeString(KEY_LIST)} INTEGER," + + //"${DatabaseUtils.sqlEscapeString(KEY_TAG)} TEXT not null," + + "${DatabaseUtils.sqlEscapeString(KEY_RESPONSE)} INTEGER," + // block: 1, allow: -1, noop: 0 + // stuff for UnifiedFilter: pattern, filterType, contentType, ignoreCase, isRegex, thirdParty, domainMap + // using blob like the filter writer/reader is horribly slow for some reason + "${DatabaseUtils.sqlEscapeString(KEY_PATTERN)} TEXT not null," + + "${DatabaseUtils.sqlEscapeString(KEY_FILTER_TYPE)} INTEGER," + + "${DatabaseUtils.sqlEscapeString(KEY_CONTENT_TYPE)} INTEGER," + + //"${DatabaseUtils.sqlEscapeString(KEY_IGNORE_CASE)} INTEGER," + it's always false for user filters anyway + "${DatabaseUtils.sqlEscapeString(KEY_THIRD_PARTY)} INTEGER," + + "${DatabaseUtils.sqlEscapeString(KEY_DOMAIN_MAP)} TEXT not null" + + ')' + db.execSQL(createRulesTable) + } + + // Upgrading database + // if this is ever necessary, at least user rules should be preserved! + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // Drop older table if it exists + db.execSQL("DROP TABLE IF EXISTS ${DatabaseUtils.sqlEscapeString(TABLE_RULES)}") + // Create tables again + onCreate(db) + } + + override fun addRules(rules: List){ + database.apply { + beginTransaction() + + for (item in rules) { + // check filter type and complain if it's not a type that can be read? + insert(TABLE_RULES, null, item.toContentValues()) + } + + setTransactionSuccessful() + endTransaction() + } + } + + private fun UnifiedFilterResponse.toContentValues() = ContentValues(10).apply { + put(com.lin.magic.database.adblock.UserRulesDatabase.KEY_RESPONSE, response.toInt()) + put(com.lin.magic.database.adblock.UserRulesDatabase.KEY_PATTERN, filter.pattern) + put(com.lin.magic.database.adblock.UserRulesDatabase.KEY_FILTER_TYPE, filter.filterType) + put(com.lin.magic.database.adblock.UserRulesDatabase.KEY_CONTENT_TYPE, filter.contentType) + put(com.lin.magic.database.adblock.UserRulesDatabase.KEY_THIRD_PARTY, filter.thirdParty) + put(com.lin.magic.database.adblock.UserRulesDatabase.KEY_DOMAIN_MAP, filter.domains?.toDBString() ?: "") + } + + fun Boolean?.toInt(): Int { + return when { + this == false -> -1 + this == true -> 1 + else -> 0 + } + } + + override fun removeAllRules() { + database.run { + delete(TABLE_RULES, null, null) + close() + } + } + + override fun removeRule(rule: UnifiedFilterResponse) { + database.run { + delete( + TABLE_RULES, + "$KEY_RESPONSE = ? AND $KEY_PATTERN = ? AND $KEY_DOMAIN_MAP = ? AND $KEY_FILTER_TYPE = ? AND $KEY_CONTENT_TYPE = ? AND $KEY_THIRD_PARTY = ?", + arrayOf(rule.response.toInt().toString(), rule.filter.pattern, rule.filter.domains?.toDBString() ?: "", rule.filter.filterType.toString(), rule.filter.contentType.toString(), rule.filter.thirdParty.toString())) + } + } + + // page in this context: what is in blocker as pageUrl.host + // to be used for uBo style page settings, allows users to block/allow/noop requests to specific domains when on this page + // (actually could be more powerful than that, could be used for something to create something like uMatrix) + // TODO: is this actually necessary? userRules need to be in UserFilterContainer anyway, so this should actually never be called +/* override fun getRulesForPage(page: String): List { + val cursor = database.query( + TABLE_RULES, + arrayOf(KEY_PATTERN, KEY_FILTER_TYPE, KEY_CONTENT_TYPE, KEY_THIRD_PARTY, KEY_DOMAIN_MAP, KEY_RESPONSE), + "$KEY_DOMAIN_MAP = ?", + arrayOf(page), + null, + null, + null, + null + ) + val rules = mutableListOf() + while (cursor.moveToNext()) { + getFilterResponse(cursor)?.let { rules.add(it) } + } + cursor.close() + return rules + } +*/ + + // TODO: as sequence would probably be better + // tested: not faster -> any reason to switch + override fun getAllRules(): List { + val cursor = database.query( + TABLE_RULES, + arrayOf(KEY_PATTERN, KEY_FILTER_TYPE, KEY_CONTENT_TYPE, KEY_THIRD_PARTY, KEY_DOMAIN_MAP, KEY_RESPONSE), + null, + null, + null, + null, + null, + null + ) + val rules = mutableListOf() + while (cursor.moveToNext()) { + getFilterResponse(cursor)?.let { rules.add(it) } + } + cursor.close() + return rules + } + + private fun getFilterResponse(cursor: Cursor): UnifiedFilterResponse? { + val response = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_RESPONSE)).toResponse() + val pattern = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATTERN)) + val filterType = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_FILTER_TYPE)) + val contentType = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_CONTENT_TYPE)) + val ignoreCase = true + val thirdParty = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_THIRD_PARTY)) + val domains = cursor.getString(cursor.getColumnIndexOrThrow(KEY_DOMAIN_MAP)).toDomainMap() + + + val filter = when (filterType) { // only recognize filter types that are used in user rules + FILTER_TYPE_CONTAINS -> ContainsFilter(pattern, contentType, ignoreCase, domains, thirdParty) + FILTER_TYPE_HOST -> HostFilter(pattern, contentType, domains, thirdParty) + else -> return null // should not happen -> error message? + } + return UnifiedFilterResponse(filter, response) + } + + private fun Int.toResponse() = when { + this == RESPONSE_BLOCK -> true + this == RESPONSE_EXCLUSION -> false + this == RESPONSE_NOOP -> null + else -> null // should not happen + } + + private fun DomainMap.toDBString(): String { + // disable full domainMap support, user rules only have null or SingleDomainMap with include = true +/* var string = getKey(0) + "/" + (if (getValue(0)) "1" else "0") + for (i in 1 until size) { + string += "//" + getKey(i) + "/" + (if (getValue(i)) "1" else "0") + } + return string*/ + + // TODO: actually there should be an error thrown, also if getValue(0) != 1 + return if (size == 1) getKey(0) else "" + } + + private fun String.toDomainMap(): DomainMap? { + if (isEmpty()) return null + // disable full domainMap support, user rules only have null or SingleDomainMap with include = true + return SingleDomainMap(true, this) +/* val mapEntries = split("//") + when { + mapEntries.size == 1 -> { + val pair = mapEntries.first().split("/") + if (pair.size != 2) return null + return (SingleDomainMap(pair[1] == "1", pair[0])) + } + mapEntries.size > 1 -> { + val domainMap = ArrayDomainMap(mapEntries.size) + for (entry in mapEntries) { + val pair = entry.split("/") + if (pair.size != 2) continue + domainMap[pair[0]] = pair[1] == "1" + if (pair[1] == "1") + domainMap.include = true + } + return domainMap + } + else -> return null + }*/ + } + + companion object { + + // Database version + private const val DATABASE_VERSION = 1 + + // Database name + private const val DATABASE_NAME = "rulesDatabase" + + // Host table name + private const val TABLE_RULES = "rules" + + // Host table columns names + // ignoreCase not necessary for user rules because user rules are hostFilter or containsFilter, none use ignoreCase + // tag not necessary because user rules because user rules use pattern (pageDomain or empty) like a tag + private const val KEY_ID = "id" + private const val KEY_RESPONSE = "response" + private const val KEY_PATTERN = "pattern" + private const val KEY_FILTER_TYPE = "filter_type" + private const val KEY_CONTENT_TYPE = "content_type" + private const val KEY_THIRD_PARTY = "third_party" + private const val KEY_DOMAIN_MAP = "domain_map" + + } + +} diff --git a/app/src/main/java/com/lin/magic/database/adblock/UserRulesRepository.kt b/app/src/main/java/com/lin/magic/database/adblock/UserRulesRepository.kt new file mode 100644 index 00000000..a5db5854 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/adblock/UserRulesRepository.kt @@ -0,0 +1,32 @@ +package com.lin.magic.database.adblock + +import com.lin.magic.adblock.UnifiedFilterResponse + +/** + * A repository that stores [Host]. + */ +interface UserRulesRepository { + + fun addRules(rules: List) + + /** + * Remove all hosts in the repository. + */ + fun removeAllRules() + + // better use sequence or completable, but currently whatever + // sequence is not faster (tested), so no use + + fun removeRule(rule: UnifiedFilterResponse) + + // actually not needed, better done using only userFilterContainer + //fun getRulesForPage(page: String): List + + fun getAllRules(): List + + companion object { + const val RESPONSE_BLOCK = 1 + const val RESPONSE_NOOP = 0 + const val RESPONSE_EXCLUSION = -1 + } +} diff --git a/app/src/main/java/com/lin/magic/database/bookmark/BookmarkDatabase.kt b/app/src/main/java/com/lin/magic/database/bookmark/BookmarkDatabase.kt new file mode 100644 index 00000000..1f5cce0f --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/bookmark/BookmarkDatabase.kt @@ -0,0 +1,324 @@ +package com.lin.magic.database.bookmark + +import com.lin.magic.R +import com.lin.magic.database.Bookmark +import com.lin.magic.database.asFolder +import com.lin.magic.database.databaseDelegate +import com.lin.magic.extensions.firstOrNullMap +import com.lin.magic.extensions.useMap +import android.app.Application +import android.content.ContentValues +import android.database.Cursor +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.core.database.getStringOrNull +import io.reactivex.Completable +import io.reactivex.Maybe +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The disk backed bookmark database. See [BookmarkRepository] for function documentation. + * + * Created by anthonycr on 5/6/17. + */ +@Singleton +class BookmarkDatabase @Inject constructor( + application: Application +) : SQLiteOpenHelper(application, DATABASE_NAME, null, DATABASE_VERSION), + BookmarkRepository { + + private val defaultBookmarkTitle: String = application.getString(R.string.untitled) + private val database: SQLiteDatabase by databaseDelegate() + + // Creating Tables + override fun onCreate(db: SQLiteDatabase) { + val createBookmarkTable = "CREATE TABLE ${DatabaseUtils.sqlEscapeString(TABLE_BOOKMARK)}(" + + "${DatabaseUtils.sqlEscapeString(KEY_ID)} INTEGER PRIMARY KEY," + + "${DatabaseUtils.sqlEscapeString(KEY_URL)} TEXT," + + "${DatabaseUtils.sqlEscapeString(KEY_TITLE)} TEXT," + + "${DatabaseUtils.sqlEscapeString(KEY_FOLDER)} TEXT," + + "${DatabaseUtils.sqlEscapeString(KEY_POSITION)} INTEGER" + + ')' + db.execSQL(createBookmarkTable) + } + + // Upgrading database + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // Drop older table if it exists + db.execSQL("DROP TABLE IF EXISTS ${DatabaseUtils.sqlEscapeString(TABLE_BOOKMARK)}") + // Create tables again + onCreate(db) + } + + /** + * Queries the database for bookmarks with the provided URL. If it + * cannot find any bookmarks with the given URL, it will try to query + * for bookmarks with the [.alternateSlashUrl] as its URL. + * + * @param url the URL to query for. + * @return a cursor with bookmarks matching the URL. + */ + private fun queryWithOptionalEndSlash(url: String): Cursor { + val alternateUrl = alternateSlashUrl(url) + return database.query( + TABLE_BOOKMARK, + null, + "$KEY_URL=? OR $KEY_URL=?", + arrayOf(url, alternateUrl), + null, + null, + null, + "1" + ) + } + + /** + * Deletes a bookmark from the database with the provided URL. If it + * cannot find any bookmark with the given URL, it will try to delete + * a bookmark with the [.alternateSlashUrl] as its URL. + * + * @param url the URL to delete. + * @return the number of deleted rows. + */ + private fun deleteWithOptionalEndSlash(url: String): Int { + return database.delete( + TABLE_BOOKMARK, + "$KEY_URL=? OR $KEY_URL=?", + arrayOf(url, alternateSlashUrl(url)) + ) + } + + /** + * Updates a bookmark in the database with the provided URL. If it + * cannot find any bookmark with the given URL, it will try to update + * a bookmark with the [.alternateSlashUrl] as its URL. + * + * @param url the URL to update. + * @param contentValues the new values to update to. + * @return the number of rows updated. + */ + private fun updateWithOptionalEndSlash(url: String, contentValues: ContentValues): Int { + var updatedRows = database.update( + TABLE_BOOKMARK, + contentValues, + "$KEY_URL=?", + arrayOf(url) + ) + + if (updatedRows == 0) { + val alternateUrl = alternateSlashUrl(url) + updatedRows = database.update( + TABLE_BOOKMARK, + contentValues, + "$KEY_URL=?", + arrayOf(alternateUrl) + ) + } + + return updatedRows + } + + override fun findBookmarkForUrl(url: String): Maybe = Maybe.fromCallable { + return@fromCallable queryWithOptionalEndSlash(url).firstOrNullMap { it.bindToBookmarkEntry() } + } + + override fun isBookmark(url: String): Single = Single.fromCallable { + queryWithOptionalEndSlash(url).use { + return@fromCallable it.moveToFirst() + } + } + + override fun addBookmarkIfNotExists(entry: Bookmark.Entry): Single = Single.fromCallable { + queryWithOptionalEndSlash(entry.url).use { + if (it.moveToFirst()) { + return@fromCallable false + } + } + + val id = database.insert( + TABLE_BOOKMARK, + null, + entry.bindBookmarkToContentValues() + ) + + return@fromCallable id != -1L + } + + /** + * + */ + override fun addBookmarkList(bookmarkItems: List) { + database.apply { + beginTransaction() + + for (item in bookmarkItems) { + addBookmarkIfNotExists(item).subscribe() + } + + setTransactionSuccessful() + endTransaction() + } + } + + override fun deleteBookmark(entry: Bookmark.Entry): Single = Single.fromCallable { + return@fromCallable deleteWithOptionalEndSlash(entry.url) > 0 + } + + override fun renameFolder(oldName: String, newName: String): Completable = Completable.fromAction { + val contentValues = ContentValues(1).apply { + put(KEY_FOLDER, newName) + } + + database.update(TABLE_BOOKMARK, contentValues, "$KEY_FOLDER=?", arrayOf(oldName)) + } + + override fun deleteFolder(folderToDelete: String): Completable = Completable.fromAction { + renameFolder(folderToDelete, "").subscribe() + } + + override fun deleteAllBookmarks(): Completable = Completable.fromAction { + database.run { + delete(TABLE_BOOKMARK, null, null) + close() + } + } + + override fun editBookmark(oldBookmark: Bookmark.Entry, newBookmark: Bookmark.Entry): Completable = Completable.fromAction { + val contentValues = newBookmark.bindBookmarkToContentValues() + + updateWithOptionalEndSlash(oldBookmark.url, contentValues) + } + + /** + * Notably used for suggestions search. + */ + override fun getAllBookmarksSorted(): Single> = Single.fromCallable { + return@fromCallable database.query( + TABLE_BOOKMARK, + null, + null, + null, + null, + null, + "$KEY_FOLDER, $KEY_POSITION ASC, $KEY_TITLE COLLATE NOCASE ASC, $KEY_URL ASC" + ).useMap { it.bindToBookmarkEntry() } + } + + /** + * + */ + override fun getBookmarksFromFolderSorted(folder: String?): Single> = Single.fromCallable { + val finalFolder = folder ?: "" + return@fromCallable database.query( + TABLE_BOOKMARK, + null, + "$KEY_FOLDER=?", + arrayOf(finalFolder), + null, + null, + "$KEY_POSITION ASC, $KEY_TITLE COLLATE NOCASE ASC, $KEY_URL ASC" + ).useMap { it.bindToBookmarkEntry() } + } + + override fun getFoldersSorted(): Single> = Single.fromCallable { + return@fromCallable database + .query( + true, + TABLE_BOOKMARK, + arrayOf(KEY_FOLDER), + null, + null, + null, + null, + "$KEY_FOLDER ASC", + null + ) + .useMap { it.getString(it.getColumnIndex(KEY_FOLDER)) } + .filter { !it.isNullOrEmpty() } + .map(String::asFolder) + } + + override fun getFolderNames(): Single> = Single.fromCallable { + return@fromCallable database.query( + true, + TABLE_BOOKMARK, + arrayOf(KEY_FOLDER), + null, + null, + null, + null, + "$KEY_FOLDER ASC", + null + ).useMap { it.getString(it.getColumnIndex(KEY_FOLDER)) } + .filter { !it.isNullOrEmpty() } + } + + override fun count(): Long = DatabaseUtils.queryNumEntries(database, TABLE_BOOKMARK) + + /** + * Binds a [Bookmark.Entry] to [ContentValues]. + * + * @return a valid values object that can be inserted into the database. + */ + private fun Bookmark.Entry.bindBookmarkToContentValues() = ContentValues(4).apply { + put(KEY_TITLE, title.takeIf(String::isNotBlank) ?: defaultBookmarkTitle) + put(KEY_URL, url) + put(KEY_FOLDER, folder.title) + put(KEY_POSITION, position) + } + + /** + * Binds a cursor to a [Bookmark.Entry]. This is + * a non consuming operation on the cursor. Note that + * this operation is not safe to perform on a cursor + * unless you know that the cursor is of history items. + * + * @return a valid item containing all the pertinent information. + */ + private fun Cursor.bindToBookmarkEntry() = Bookmark.Entry( + url = getString(getColumnIndex(KEY_URL)), + title = getString(getColumnIndex(KEY_TITLE)), + folder = getStringOrNull(getColumnIndex(KEY_FOLDER)).asFolder(), + position = getInt(getColumnIndex(KEY_POSITION)) + ) + + /** + * URLs can represent the same thing with or without a trailing slash, + * for instance, google.com/ is the same page as google.com. Since these + * can be represented as different bookmarks within the bookmark database, + * it is important to be able to get the alternate version of a URL. + * + * @param url the string that might have a trailing slash. + * @return a string without a trailing slash if the original had one, + * or a string with a trailing slash if the original did not. + */ + private fun alternateSlashUrl(url: String): String = if (url.endsWith("/")) { + url.substring(0, url.length - 1) + } else { + "$url/" + } + + companion object { + + // Database version + private const val DATABASE_VERSION = 1 + + // Database name + private const val DATABASE_NAME = "bookmarkManager" + + // Bookmark table name + private const val TABLE_BOOKMARK = "bookmark" + + // Bookmark table columns names + private const val KEY_ID = "id" + private const val KEY_URL = "url" + private const val KEY_TITLE = "title" + private const val KEY_FOLDER = "folder" + private const val KEY_POSITION = "position" + + } + +} diff --git a/app/src/main/java/com/lin/magic/database/bookmark/BookmarkExporter.java b/app/src/main/java/com/lin/magic/database/bookmark/BookmarkExporter.java new file mode 100644 index 00000000..a1721bc5 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/bookmark/BookmarkExporter.java @@ -0,0 +1,155 @@ +package com.lin.magic.database.bookmark; + +import android.content.Context; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.ArrayList; +import java.util.List; +import com.lin.magic.R; +import com.lin.magic.database.Bookmark; +import com.lin.magic.utils.Preconditions; +import com.lin.magic.utils.Utils; +import androidx.annotation.NonNull; + +import com.lin.magic.database.WebPageKt; +import io.reactivex.Completable; +import timber.log.Timber; + +/** + * The class responsible for importing and exporting + * bookmarks in the JSON format. + *

+ * Created by anthonycr on 5/7/17. + */ +public final class BookmarkExporter { + + private static final String TAG = "BookmarkExporter"; + + private static final String KEY_URL = "url"; + private static final String KEY_TITLE = "title"; + private static final String KEY_FOLDER = "folder"; + private static final String KEY_ORDER = "order"; + + private BookmarkExporter() {} + + /** + * Retrieves all the default bookmarks stored + * in the raw file within assets. + * + * @param context the context necessary to open assets. + * @return a non null list of the bookmarks stored in assets. + */ + @NonNull + public static List importBookmarksFromAssets(@NonNull Context context) { + List bookmarks = new ArrayList<>(); + BufferedReader bookmarksReader = null; + InputStream inputStream = null; + try { + inputStream = context.getResources().openRawResource(R.raw.default_bookmarks); + //noinspection IOResourceOpenedButNotSafelyClosed + bookmarksReader = new BufferedReader(new InputStreamReader(inputStream)); + String line; + while ((line = bookmarksReader.readLine()) != null) { + try { + JSONObject object = new JSONObject(line); + final String folderTitle = object.getString(KEY_FOLDER); + bookmarks.add( + new Bookmark.Entry( + object.getString(KEY_URL), + object.getString(KEY_TITLE), + object.getInt(KEY_ORDER), + WebPageKt.asFolder(folderTitle) + ) + ); + } catch (JSONException e) { + Timber.e(e, "Can't parse line %s", line); + } + } + } catch (IOException e) { + Timber.e(e, "Error reading the bookmarks file"); + } finally { + Utils.close(bookmarksReader); + Utils.close(inputStream); + } + + return bookmarks; + } + + /** + * Exports the list of bookmarks to a file. + * + * @param bookmarkList the bookmarks to export. + * @param aStream the stream to export to. + * @return an observable that emits a completion + * event when the export is complete, or an error + * event if there is a problem. + */ + @NonNull + public static Completable exportBookmarksToFile(@NonNull final List bookmarkList, + @NonNull final OutputStream aStream) { + return Completable.fromAction(() -> { + Preconditions.checkNonNull(bookmarkList); + BufferedWriter bookmarkWriter = null; + try { + //noinspection IOResourceOpenedButNotSafelyClosed + bookmarkWriter = new BufferedWriter(new OutputStreamWriter(aStream)); + + JSONObject object = new JSONObject(); + for (Bookmark.Entry item : bookmarkList) { + object.put(KEY_TITLE, item.getTitle()); + object.put(KEY_URL, item.getUrl()); + object.put(KEY_FOLDER, item.getFolder().getTitle()); + object.put(KEY_ORDER, item.getPosition()); + bookmarkWriter.write(object.toString()); + bookmarkWriter.newLine(); + } + } finally { + Utils.close(bookmarkWriter); + } + }); + } + + /** + * Attempts to import bookmarks from the + * given file. If the file is not in a + * supported format, it will fail. + * + * @param inputStream The stream to import from. + * @return A list of bookmarks, or throws an exception if the bookmarks cannot be imported. + */ + @NonNull + public static List importBookmarksFromFileStream(@NonNull InputStream inputStream) throws Exception { + BufferedReader bookmarksReader = null; + try { + //noinspection IOResourceOpenedButNotSafelyClosed + bookmarksReader = new BufferedReader(new InputStreamReader(inputStream)); + String line; + + List bookmarks = new ArrayList<>(); + while ((line = bookmarksReader.readLine()) != null) { + JSONObject object = new JSONObject(line); + final String folderName = object.getString(KEY_FOLDER); + final Bookmark.Entry entry = new Bookmark.Entry( + object.getString(KEY_URL), + object.getString(KEY_TITLE), + object.getInt(KEY_ORDER), + WebPageKt.asFolder(folderName) + ); + bookmarks.add(entry); + } + + return bookmarks; + } finally { + Utils.close(bookmarksReader); + } + } +} diff --git a/app/src/main/java/com/lin/magic/database/bookmark/BookmarkRepository.kt b/app/src/main/java/com/lin/magic/database/bookmark/BookmarkRepository.kt new file mode 100644 index 00000000..735f9aa1 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/bookmark/BookmarkRepository.kt @@ -0,0 +1,128 @@ +package com.lin.magic.database.bookmark + +import com.lin.magic.database.Bookmark +import androidx.annotation.WorkerThread +import io.reactivex.Completable +import io.reactivex.Maybe +import io.reactivex.Single + +/** + * The interface that should be used to communicate with the bookmark database. + * + * Created by anthonycr on 5/6/17. + */ +interface BookmarkRepository { + + /** + * Gets the bookmark associated with the URL. + * + * @param url the URL to look for. + * @return an observable that will emit either the bookmark associated with the URL or null. + */ + fun findBookmarkForUrl(url: String): Maybe + + /** + * Determines if a URL is associated with a bookmark. + * + * @param url the URL to check. + * @return an observable that will emit true if the URL is a bookmark, false otherwise. + */ + fun isBookmark(url: String): Single + + /** + * Adds a bookmark if one does not already exist with the same URL. + * + * @param entry the bookmark to add. + * @return an observable that emits true if the bookmark was added, false otherwise. + */ + fun addBookmarkIfNotExists(entry: Bookmark.Entry): Single + + /** + * Adds a list of bookmarks to the database. + * + * @param bookmarkItems the bookmarks to add. + */ + fun addBookmarkList(bookmarkItems: List) + + /** + * Deletes a bookmark from the database. The [Bookmark.Entry.url] is used to delete the + * bookmark. + * + * @param entry the bookmark to delete. + * @return an observable that emits true when the entry is deleted, false otherwise. + */ + fun deleteBookmark(entry: Bookmark.Entry): Single + + /** + * Moves all bookmarks in the old folder to the new folder. + * + * @param oldName the name of the old folder. + * @param newName the name of the new folder. + * @return an observable that emits a completion event when the folder is renamed. + */ + fun renameFolder(oldName: String, newName: String): Completable + + /** + * Deletes a folder from the database, all bookmarks in that folder will be moved to the root + * level. + * + * @param folderToDelete the folder to delete. + * @return an observable that emits a completion event when the folder has been deleted. + */ + fun deleteFolder(folderToDelete: String): Completable + + /** + * Deletes all bookmarks in the database. + * + * @return an observable that emits a completion event when all bookmarks have been deleted. + */ + fun deleteAllBookmarks(): Completable + + /** + * Changes the bookmark with the original URL with all the data from the new bookmark. + * + * @param oldBookmark the old bookmark to replace. + * @param newBookmark the new bookmark. + * @return an observable that emits a completion event when the bookmark edit is done. + */ + fun editBookmark(oldBookmark: Bookmark.Entry, newBookmark: Bookmark.Entry): Completable + + /** + * Emits a list of all bookmarks, sorted by folder, position, title, and url. + * + * @return an observable that emits a list of all bookmarks. + */ + fun getAllBookmarksSorted(): Single> + + /** + * Emits all bookmarks in a certain folder. If the folder chosen is null, then all bookmarks + * without a specified folder will be returned. + * + * @param folder gets the bookmarks from this folder, may be null. + * @return an observable that emits a list of bookmarks in the given folder. + */ + fun getBookmarksFromFolderSorted(folder: String?): Single> + + /** + * Returns all folders as [Bookmark.Folder]. The root folder is omitted. + * + * @return an observable that emits a list of folders. + */ + fun getFoldersSorted(): Single> + + /** + * Returns the names of all folders. The root folder is omitted. + * + * @return an observable that emits a list of folder names. + */ + fun getFolderNames(): Single> + + /** + * A synchronous call to the model that returns the number of bookmarks. Should be called from a + * background thread. + * + * @return the number of bookmarks in the database. + */ + @WorkerThread + fun count(): Long +} diff --git a/app/src/main/java/com/lin/magic/database/downloads/DownloadEntry.kt b/app/src/main/java/com/lin/magic/database/downloads/DownloadEntry.kt new file mode 100644 index 00000000..0efcb9a1 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/downloads/DownloadEntry.kt @@ -0,0 +1,14 @@ +package com.lin.magic.database.downloads + +/** + * An entry in the downloads database. + * + * @param url The URL of the original download. + * @param title The file name. + * @param contentSize The user readable content size. + */ +data class DownloadEntry( + val url: String, + val title: String, + val contentSize: String +) diff --git a/app/src/main/java/com/lin/magic/database/downloads/DownloadsDatabase.kt b/app/src/main/java/com/lin/magic/database/downloads/DownloadsDatabase.kt new file mode 100644 index 00000000..29f8ba5b --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/downloads/DownloadsDatabase.kt @@ -0,0 +1,170 @@ +package com.lin.magic.database.downloads + +import com.lin.magic.database.databaseDelegate +import com.lin.magic.extensions.firstOrNullMap +import com.lin.magic.extensions.useMap +import android.app.Application +import android.content.ContentValues +import android.database.Cursor +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import io.reactivex.Completable +import io.reactivex.Maybe +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The disk backed download database. See [DownloadsRepository] for function documentation. + */ +@Singleton +class DownloadsDatabase @Inject constructor( + application: Application +) : SQLiteOpenHelper(application, DATABASE_NAME, null, DATABASE_VERSION), + DownloadsRepository { + + private val database: SQLiteDatabase by databaseDelegate() + + // Creating Tables + override fun onCreate(db: SQLiteDatabase) { + val createDownloadsTable = "CREATE TABLE ${DatabaseUtils.sqlEscapeString(TABLE_DOWNLOADS)}(" + + "${DatabaseUtils.sqlEscapeString(KEY_ID)} INTEGER PRIMARY KEY," + + "${DatabaseUtils.sqlEscapeString(KEY_URL)} TEXT," + + "${DatabaseUtils.sqlEscapeString(KEY_TITLE)} TEXT," + + "${DatabaseUtils.sqlEscapeString(KEY_SIZE)} TEXT" + + ')' + db.execSQL(createDownloadsTable) + } + + // Upgrading database + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // Drop older table if it exists + db.execSQL("DROP TABLE IF EXISTS ${DatabaseUtils.sqlEscapeString(TABLE_DOWNLOADS)}") + // Create tables again + onCreate(db) + } + + override fun findDownloadForUrl(url: String): Maybe = Maybe.fromCallable { + database.query( + TABLE_DOWNLOADS, + null, + "$KEY_URL=?", + arrayOf(url), + null, + null, + "1" + ).firstOrNullMap { it.bindToDownloadItem() } + } + + override fun isDownload(url: String): Single = Single.fromCallable { + database.query( + TABLE_DOWNLOADS, + null, + "$KEY_URL=?", + arrayOf(url), + null, + null, + null, + "1" + ).use { + return@fromCallable it.moveToFirst() + } + } + + override fun addDownloadIfNotExists(entry: DownloadEntry): Single = Single.fromCallable { + database.query( + TABLE_DOWNLOADS, + null, + "$KEY_URL=?", + arrayOf(entry.url), + null, + null, + "1" + ).use { + if (it.moveToFirst()) { + return@fromCallable false + } + } + + val id = database.insert(TABLE_DOWNLOADS, null, entry.toContentValues()) + + return@fromCallable id != -1L + } + + override fun addDownloadsList(downloadEntries: List): Completable = Completable.fromAction { + database.apply { + beginTransaction() + setTransactionSuccessful() + + for (item in downloadEntries) { + addDownloadIfNotExists(item).subscribe() + } + + endTransaction() + } + } + + override fun deleteDownload(url: String): Single = Single.fromCallable { + return@fromCallable database.delete(TABLE_DOWNLOADS, "$KEY_URL=?", arrayOf(url)) > 0 + } + + override fun deleteAllDownloads(): Completable = Completable.fromAction { + database.run { + delete(TABLE_DOWNLOADS, null, null) + close() + } + } + + override fun getAllDownloads(): Single> = Single.fromCallable { + return@fromCallable database.query( + TABLE_DOWNLOADS, + null, + null, + null, + null, + null, + "$KEY_ID DESC" + ).useMap { it.bindToDownloadItem() } + } + + override fun count(): Long = DatabaseUtils.queryNumEntries(database, TABLE_DOWNLOADS) + + /** + * Maps the fields of [DownloadEntry] to [ContentValues]. + */ + private fun DownloadEntry.toContentValues() = ContentValues(3).apply { + put(KEY_TITLE, title) + put(KEY_URL, url) + put(KEY_SIZE, contentSize) + } + + /** + * Binds a [Cursor] to a single [DownloadEntry]. + */ + private fun Cursor.bindToDownloadItem() = DownloadEntry( + url = getString(getColumnIndex(KEY_URL)), + title = getString(getColumnIndex(KEY_TITLE)), + contentSize = getString(getColumnIndex(KEY_SIZE)) + ) + + companion object { + + // Database version + private const val DATABASE_VERSION = 1 + + // Database name + private const val DATABASE_NAME = "downloadManager" + + // DownloadItem table name + private const val TABLE_DOWNLOADS = "download" + + // DownloadItem table columns names + private const val KEY_ID = "id" + private const val KEY_URL = "url" + private const val KEY_TITLE = "title" + private const val KEY_SIZE = "size" + + } + +} diff --git a/app/src/main/java/com/lin/magic/database/downloads/DownloadsRepository.kt b/app/src/main/java/com/lin/magic/database/downloads/DownloadsRepository.kt new file mode 100644 index 00000000..e527a70b --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/downloads/DownloadsRepository.kt @@ -0,0 +1,77 @@ +package com.lin.magic.database.downloads + +import androidx.annotation.WorkerThread +import io.reactivex.Completable +import io.reactivex.Maybe +import io.reactivex.Single + +/** + * The interface that should be used to communicate with the download database. + * + * Created by df1e on 29/5/17. + */ +interface DownloadsRepository { + + /** + * Determines if a URL is associated with a download. + * + * @param url the URL to check. + * @return an observable that will emit true if the URL is a download, false otherwise. + */ + fun isDownload(url: String): Single + + /** + * Gets the download associated with the URL. + * + * @param url the URL to look for. + * @return an observable that will emit either the download associated with the URL or null. + */ + fun findDownloadForUrl(url: String): Maybe + + /** + * Adds a download if one does not already exist with the same URL. + * + * @param entry the download to add. + * @return an observable that emits true if the download was added, false otherwise. + */ + fun addDownloadIfNotExists(entry: DownloadEntry): Single + + /** + * Adds a list of downloads to the database. + * + * @param downloadEntries the downloads to add. + * @return an observable that emits a complete event when all the downloads have been added. + */ + fun addDownloadsList(downloadEntries: List): Completable + + /** + * Deletes a download from the database. + * + * @param url the download url to delete. + * @return an observable that emits true when the download is deleted, false otherwise. + */ + fun deleteDownload(url: String): Single + + /** + * Deletes all downloads in the database. + * + * @return an observable that emits a completion event when all downloads have been deleted. + */ + fun deleteAllDownloads(): Completable + + /** + * Emits a list of all downloads, sorted by primary key. + * + * @return an observable that emits a list of all downloads. + */ + fun getAllDownloads(): Single> + + /** + * A synchronous call to the model that returns the number of downloads. Should be called from a + * background thread. + * + * @return the number of downloads in the database. + */ + @WorkerThread + fun count(): Long +} diff --git a/app/src/main/java/com/lin/magic/database/history/HistoryDatabase.kt b/app/src/main/java/com/lin/magic/database/history/HistoryDatabase.kt new file mode 100644 index 00000000..f26e7ab7 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/history/HistoryDatabase.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2014 A.C.R. Development + */ +package com.lin.magic.database.history + +import com.lin.magic.database.HistoryEntry +import com.lin.magic.database.databaseDelegate +import com.lin.magic.extensions.firstOrNullMap +import com.lin.magic.extensions.useMap +import android.app.Application +import android.content.ContentValues +import android.database.Cursor +import android.database.DatabaseUtils +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.annotation.WorkerThread +import io.reactivex.Completable +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Singleton + + +/** + * The disk backed download database. See [HistoryRepository] for function documentation. + */ +@Singleton +@WorkerThread +class HistoryDatabase @Inject constructor( + application: Application +) : SQLiteOpenHelper(application, DATABASE_NAME, null, DATABASE_VERSION), + HistoryRepository { + + private val database: SQLiteDatabase by databaseDelegate() + + // Creating Tables + override fun onCreate(db: SQLiteDatabase) { + val createHistoryTable = "CREATE TABLE $TABLE_HISTORY(" + + " $KEY_ID INTEGER PRIMARY KEY," + + " $KEY_URL TEXT," + + " $KEY_TITLE TEXT," + + " $KEY_TIME_VISITED INTEGER" + + ")" + db.execSQL(createHistoryTable) + } + + // Upgrading database + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // Drop older table if it exists + db.execSQL("DROP TABLE IF EXISTS $TABLE_HISTORY") + // Create tables again + onCreate(db) + } + + override fun deleteHistory(): Completable = Completable.fromAction { + database.run { + delete(TABLE_HISTORY, null, null) + close() + } + } + + override fun deleteHistoryEntry(url: String): Completable = Completable.fromAction { + database.delete(TABLE_HISTORY, "$KEY_URL = ?", arrayOf(url)) + } + + override fun visitHistoryEntry(url: String, title: String?): Completable = Completable.fromAction { + val values = ContentValues().apply { + put(KEY_TITLE, title ?: "") + put(KEY_TIME_VISITED, System.currentTimeMillis()) + } + + database.query( + false, + TABLE_HISTORY, + arrayOf(KEY_ID), + "$KEY_URL = ?", + arrayOf(url), + null, + null, + null, + "1" + ).use { + if (it.count > 0) { + database.update(TABLE_HISTORY, values, "$KEY_URL = ?", arrayOf(url)) + } else { + addHistoryEntry(HistoryEntry(url, title ?: "")) + } + } + } + + override fun findHistoryEntriesContaining(query: String): Single> = + Single.fromCallable { + val search = "%$query%" + + return@fromCallable database.query( + TABLE_HISTORY, + null, + "$KEY_TITLE LIKE ? OR $KEY_URL LIKE ?", + arrayOf(search, search), + null, + null, + "$KEY_TIME_VISITED DESC", + "5" + ).useMap { it.bindToHistoryEntry() } + } + + override fun lastHundredVisitedHistoryEntries(): Single> = + Single.fromCallable { + database.query( + TABLE_HISTORY, + null, + null, + null, + null, + null, + "$KEY_TIME_VISITED DESC", + "100" + ).useMap { it.bindToHistoryEntry() } + } + + @WorkerThread + private fun addHistoryEntry(item: HistoryEntry) { + database.insert(TABLE_HISTORY, null, item.toContentValues()) + } + + @WorkerThread + fun getHistoryEntry(url: String): String? = + database.query( + TABLE_HISTORY, + arrayOf(KEY_ID, KEY_URL, KEY_TITLE), + "$KEY_URL = ?", + arrayOf(url), + null, + null, + null, + "1" + ).firstOrNullMap { it.getString(0) } + + + fun getAllHistoryEntries(): List { + return database.query( + TABLE_HISTORY, + null, + null, + null, + null, + null, + "$KEY_TIME_VISITED DESC" + ).useMap { it.bindToHistoryEntry() } + } + + fun getHistoryEntriesCount(): Long = DatabaseUtils.queryNumEntries(database, TABLE_HISTORY) + + private fun HistoryEntry.toContentValues() = ContentValues().apply { + put(com.lin.magic.database.history.HistoryDatabase.KEY_URL, url) + put(com.lin.magic.database.history.HistoryDatabase.KEY_TITLE, title) + put(com.lin.magic.database.history.HistoryDatabase.KEY_TIME_VISITED, lastTimeVisited) + } + + private fun Cursor.bindToHistoryEntry() = HistoryEntry( + url = getString(1), + title = getString(2), + lastTimeVisited = getLong(3) + ) + + companion object { + + // Database version + private const val DATABASE_VERSION = 2 + + // Database name + private const val DATABASE_NAME = "historyManager" + + // HistoryEntry table name + private const val TABLE_HISTORY = "history" + + // HistoryEntry table columns names + private const val KEY_ID = "id" + private const val KEY_URL = "url" + private const val KEY_TITLE = "title" + private const val KEY_TIME_VISITED = "time" + + } +} diff --git a/app/src/main/java/com/lin/magic/database/history/HistoryRepository.kt b/app/src/main/java/com/lin/magic/database/history/HistoryRepository.kt new file mode 100644 index 00000000..d55e14d1 --- /dev/null +++ b/app/src/main/java/com/lin/magic/database/history/HistoryRepository.kt @@ -0,0 +1,56 @@ +package com.lin.magic.database.history + +import com.lin.magic.database.HistoryEntry +import io.reactivex.Completable +import io.reactivex.Single + +/** + * An interface that should be used to communicate with the history database. + * + * Created by anthonycr on 6/9/17. + */ +interface HistoryRepository { + + /** + * An observable that deletes browser history. + * + * @return a valid observable. + */ + fun deleteHistory(): Completable + + /** + * An observable that deletes the history entry with the specific URL. + * + * @param url the URL of the item to delete. + * @return a valid observable. + */ + fun deleteHistoryEntry(url: String): Completable + + /** + * An observable that visits the URL by adding it to the database if it doesn't exist or + * updating the time visited if it does. + * + * @param url the URL of the item that was visited. + * @param title the title of the item that was visited. + * @return a valid observable. + */ + fun visitHistoryEntry(url: String, title: String?): Completable + + /** + * An observable that finds all history items containing the given query. If the query is + * contained anywhere within the title or the URL of the history item, it will be returned. For + * the sake of performance, only the first five items will be emitted. + * + * @param query the query to search for. + * @return a valid observable that emits + * a list of history items. + */ + fun findHistoryEntriesContaining(query: String): Single> + + /** + * An observable that emits a list of the last 100 visited history items. + * + * @return a valid observable that emits a list of history items. + */ + fun lastHundredVisitedHistoryEntries(): Single> +} diff --git a/app/src/main/java/com/lin/magic/device/BuildInfo.kt b/app/src/main/java/com/lin/magic/device/BuildInfo.kt new file mode 100644 index 00000000..4d3251db --- /dev/null +++ b/app/src/main/java/com/lin/magic/device/BuildInfo.kt @@ -0,0 +1,14 @@ +package com.lin.magic.device + +/** + * A representation of the info for the current build. + */ +data class BuildInfo(val buildType: BuildType) + +/** + * The types of builds that this instance of the app could be. + */ +enum class BuildType { + DEBUG, + RELEASE +} diff --git a/app/src/main/java/com/lin/magic/device/ScreenSize.kt b/app/src/main/java/com/lin/magic/device/ScreenSize.kt new file mode 100644 index 00000000..7e3fed0e --- /dev/null +++ b/app/src/main/java/com/lin/magic/device/ScreenSize.kt @@ -0,0 +1,19 @@ +package com.lin.magic.device + +import android.content.Context +import android.content.res.Configuration +import dagger.Reusable +import javax.inject.Inject + +/** + * A model used to determine the screen size info. + * + * Created by anthonycr on 2/19/18. + */ +@Reusable +class ScreenSize @Inject constructor(private val context: Context) { + + fun isTablet(): Boolean = + context.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK == Configuration.SCREENLAYOUT_SIZE_XLARGE + +} diff --git a/app/src/main/java/com/lin/magic/di/AppBindsModule.kt b/app/src/main/java/com/lin/magic/di/AppBindsModule.kt new file mode 100644 index 00000000..8d185fd7 --- /dev/null +++ b/app/src/main/java/com/lin/magic/di/AppBindsModule.kt @@ -0,0 +1,41 @@ +package com.lin.magic.di + +import com.lin.magic.browser.cleanup.DelegatingExitCleanup +import com.lin.magic.browser.cleanup.ExitCleanup +import com.lin.magic.database.adblock.UserRulesDatabase +import com.lin.magic.database.adblock.UserRulesRepository +import com.lin.magic.database.bookmark.BookmarkDatabase +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.database.downloads.DownloadsDatabase +import com.lin.magic.database.downloads.DownloadsRepository +import com.lin.magic.database.history.HistoryDatabase +import com.lin.magic.database.history.HistoryRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Dependency injection module used to bind implementations to interfaces. + * SL: Looks like those are still actually needed. + */ +@Module +@InstallIn(SingletonComponent::class) +interface AppBindsModule { + + @Binds + fun bindsExitCleanup(delegatingExitCleanup: DelegatingExitCleanup): ExitCleanup + + @Binds + fun bindsBookmarkModel(bookmarkDatabase: BookmarkDatabase): BookmarkRepository + + @Binds + fun bindsDownloadsModel(downloadsDatabase: DownloadsDatabase): DownloadsRepository + + @Binds + fun bindsHistoryModel(historyDatabase: HistoryDatabase): HistoryRepository + + @Binds + fun bindsAbpRulesRepository(apbRulesDatabase: UserRulesDatabase): UserRulesRepository + +} diff --git a/app/src/main/java/com/lin/magic/di/AppModule.kt b/app/src/main/java/com/lin/magic/di/AppModule.kt new file mode 100644 index 00000000..fb347015 --- /dev/null +++ b/app/src/main/java/com/lin/magic/di/AppModule.kt @@ -0,0 +1,246 @@ +package com.lin.magic.di + +import com.lin.magic.BuildConfig +import com.lin.magic.device.BuildInfo +import com.lin.magic.device.BuildType +import com.lin.magic.extensions.landscapeSharedPreferencesName +import com.lin.magic.extensions.portraitSharedPreferencesName +import com.lin.magic.html.ListPageReader +import com.lin.magic.html.bookmark.BookmarkPageReader +import com.lin.magic.html.homepage.HomePageReader +import com.lin.magic.js.InvertPage +import com.lin.magic.js.TextReflow +import com.lin.magic.js.ThemeColor +import com.lin.magic.js.SetMetaViewport +import com.lin.magic.search.suggestions.RequestFactory +import android.app.Application +import android.app.DownloadManager +import android.app.NotificationManager +import android.content.ClipboardManager +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.ShortcutManager +import android.content.res.AssetManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import androidx.preference.PreferenceManager +import com.anthonycr.mezzanine.MezzanineGenerator +import com.lin.magic.html.incognito.IncognitoPageReader +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.reactivex.Scheduler +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import net.i2p.android.ui.I2PAndroidHelper +import okhttp3.* +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingDeque +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class AppModule { + + @Provides + fun provideBuildInfo(): BuildInfo = BuildInfo(when { + BuildConfig.DEBUG -> BuildType.DEBUG + else -> BuildType.RELEASE + }) + + @Provides + @MainHandler + fun provideMainHandler() = Handler(Looper.getMainLooper()) + + @Provides + fun provideContext(application: Application): Context = application.applicationContext + + + @Provides + @UserPrefs + @Singleton + // Access default shared preferences to make sure preferences framework binding is working from XML + fun provideUserSharedPreferences(application: Application): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application.applicationContext) + + @Provides + @PrefsPortrait + @Singleton + fun providePreferencesPortrait(application: Application): SharedPreferences = application.getSharedPreferences(application.portraitSharedPreferencesName(), 0) + + @Provides + @PrefsLandscape + @Singleton + fun providePreferencesLandscape(application: Application): SharedPreferences = application.getSharedPreferences(application.landscapeSharedPreferencesName(), 0) + + @Provides + @DevPrefs + fun provideDebugPreferences(application: Application): SharedPreferences = application.getSharedPreferences("developer_settings", 0) + + @Provides + @AdBlockPrefs + fun provideAdBlockPreferences(application: Application): SharedPreferences = application.getSharedPreferences("ad_block_settings", 0) + + @Provides + fun providesAssetManager(application: Application): AssetManager = application.assets + + @Provides + fun providesClipboardManager(application: Application) = application.getSystemService()!! + + @Provides + fun providesInputMethodManager(application: Application) = application.getSystemService()!! + + @Provides + fun providesDownloadManager(application: Application) = application.getSystemService()!! + + @Provides + fun providesConnectivityManager(application: Application) = application.getSystemService()!! + + @Provides + fun providesNotificationManager(application: Application) = application.getSystemService()!! + + @Provides + fun providesWindowManager(application: Application) = application.getSystemService()!! + + @RequiresApi(Build.VERSION_CODES.N_MR1) + @Provides + fun providesShortcutManager(application: Application) = application.getSystemService()!! + + @Provides + @DatabaseScheduler + @Singleton + fun providesIoThread(): Scheduler = Schedulers.from(Executors.newSingleThreadExecutor()) + + @Provides + @DiskScheduler + @Singleton + fun providesDiskThread(): Scheduler = Schedulers.from(Executors.newSingleThreadExecutor()) + + @Provides + @NetworkScheduler + @Singleton + fun providesNetworkThread(): Scheduler = Schedulers.from(ThreadPoolExecutor(0, 4, 60, TimeUnit.SECONDS, LinkedBlockingDeque())) + + @Provides + @MainScheduler + @Singleton + fun providesMainThread(): Scheduler = AndroidSchedulers.mainThread() + + @Singleton + @Provides + fun providesSuggestionsCacheControl(): CacheControl = CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build() + + @Singleton + @Provides + fun providesSuggestionsRequestFactory(cacheControl: CacheControl): RequestFactory = object : + RequestFactory { + override fun createSuggestionsRequest(httpUrl: HttpUrl, encoding: String): Request { + return Request.Builder().url(httpUrl) + .addHeader("Accept-Charset", encoding) + .cacheControl(cacheControl) + .build() + } + } + + private fun createInterceptorWithMaxCacheAge(maxCacheAgeSeconds: Long) = Interceptor { chain -> + chain.proceed(chain.request()).newBuilder() + .header("cache-control", "max-age=$maxCacheAgeSeconds, max-stale=$maxCacheAgeSeconds") + .build() + } + + @Singleton + @Provides + @SuggestionsClient + fun providesSuggestionsHttpClient(application: Application): Single = Single.fromCallable { + val intervalDay = TimeUnit.DAYS.toSeconds(1) + val suggestionsCache = File(application.cacheDir, "suggestion_responses") + + return@fromCallable OkHttpClient.Builder() + .cache(Cache(suggestionsCache, com.lin.magic.utils.FileUtils.megabytesToBytes(1))) + .addNetworkInterceptor(createInterceptorWithMaxCacheAge(intervalDay)) + .build() + }.cache() + + @Provides + @Singleton + fun provideI2PAndroidHelper(application: Application): I2PAndroidHelper = I2PAndroidHelper(application) + + @Provides + fun providesListPageReader(): ListPageReader = MezzanineGenerator.ListPageReader() + + @Provides + fun providesHomePageReader(): HomePageReader = MezzanineGenerator.HomePageReader() + + @Provides + fun providesIncognitoPageReader(): IncognitoPageReader = MezzanineGenerator.IncognitoPageReader() + + @Provides + fun providesBookmarkPageReader(): BookmarkPageReader = MezzanineGenerator.BookmarkPageReader() + + @Provides + fun providesTextReflow(): TextReflow = MezzanineGenerator.TextReflow() + + @Provides + fun providesThemeColor(): ThemeColor = MezzanineGenerator.ThemeColor() + + @Provides + fun providesInvertPage(): InvertPage = MezzanineGenerator.InvertPage() + + @Provides + fun providesSetMetaViewport(): SetMetaViewport = MezzanineGenerator.SetMetaViewport() +} + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class SuggestionsClient + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MainHandler + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class UserPrefs + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class PrefsPortrait + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class PrefsLandscape + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class AdBlockPrefs + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class DevPrefs + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MainScheduler + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class DiskScheduler + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class NetworkScheduler + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class DatabaseScheduler diff --git a/app/src/main/java/com/lin/magic/di/DiExtensions.kt b/app/src/main/java/com/lin/magic/di/DiExtensions.kt new file mode 100644 index 00000000..146f6979 --- /dev/null +++ b/app/src/main/java/com/lin/magic/di/DiExtensions.kt @@ -0,0 +1,63 @@ +@file:JvmName("Injector") + +package com.lin.magic.di + +import com.lin.magic.App +import com.lin.magic.settings.preferences.ConfigurationPreferences +import android.content.Context +import android.content.res.Configuration +import com.lin.magic.device.ScreenSize +import com.lin.magic.extensions.configId +import com.lin.magic.settings.Config +import com.lin.magic.settings.preferences.ConfigurationCustomPreferences +import timber.log.Timber +import java.io.File + +/** + * Provides access to current configuration settings, typically either portrait or landscape variant. + */ +val Context.configPrefs: ConfigurationPreferences + get() { + return (applicationContext as App).configPreferences!! + } + +/** + * Load configuration preferences corresponding to the current configuration and make it available through our application singleton. + */ +fun Context.updateConfigPrefs() { + val currentConfigId = this.configId + Timber.d("updateConfigPrefs - $currentConfigId") + + // Build our list of custom configurations + // Configurations preferences are saved as XML files in our shared preferences folder with the Config.filePrefix + val directory = File(applicationInfo.dataDir, "shared_prefs") + if (directory.exists() && directory.isDirectory) { + val list = directory.list { _, name -> name.startsWith(Config.filePrefix) } + + list?.forEach { fileName -> + // Create config object from file name + val config = Config(fileName) + // Check if we found the current config + if (config.id == currentConfigId) { + // We have specific custom preferences for the current configuration + // Load it and make it accessible through our application singleton + (applicationContext as App).apply { + configPreferences = ConfigurationCustomPreferences(getSharedPreferences(config.fileName,0), ScreenSize(this@updateConfigPrefs)) + } + Timber.d("updateConfigPrefs - Found specific config") + // We found our config, we are done here + return + } + } + } + + // Specific config was not found, use one the generic ones then. + // That's either landscape or portrait configuration. + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + (applicationContext as App).configPreferences = (applicationContext as App).portraitPreferences + } + else { + (applicationContext as App).configPreferences = (applicationContext as App).landscapePreferences + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/di/EntryPoint.kt b/app/src/main/java/com/lin/magic/di/EntryPoint.kt new file mode 100644 index 00000000..0ea83927 --- /dev/null +++ b/app/src/main/java/com/lin/magic/di/EntryPoint.kt @@ -0,0 +1,75 @@ +package com.lin.magic.di + +import com.lin.magic.adblock.AbpBlockerManager +import com.lin.magic.adblock.AbpUserRules +import com.lin.magic.adblock.NoOpAdBlocker +import com.lin.magic.browser.TabsManager +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.database.downloads.DownloadsRepository +import com.lin.magic.database.history.HistoryRepository +import com.lin.magic.dialog.LightningDialogBuilder +import com.lin.magic.favicon.FaviconModel +import com.lin.magic.html.homepage.HomePageFactory +import com.lin.magic.js.InvertPage +import com.lin.magic.js.SetMetaViewport +import com.lin.magic.js.TextReflow +import com.lin.magic.network.NetworkConnectivityModel +import com.lin.magic.search.SearchEngineProvider +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.view.webrtc.WebRtcPermissionsModel +import android.app.DownloadManager +import android.content.ClipboardManager +import android.content.SharedPreferences +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.reactivex.Scheduler + +/** + * Provide access to all our injectable classes. + * Virtual fields can't resolve qualifiers for some reason. + * Therefore we use functions where qualifiers are needed. + * + * Just add your class here if you need it. + * + * TODO: See if and how we can use the 'by' syntax to initialize usage of those. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface HiltEntryPoint { + + val bookmarkRepository: BookmarkRepository + val userPreferences: UserPreferences + @UserPrefs + fun userSharedPreferences(): SharedPreferences + val historyRepository: HistoryRepository + @DatabaseScheduler + fun databaseScheduler(): Scheduler + @NetworkScheduler + fun networkScheduler(): Scheduler + @DiskScheduler + fun diskScheduler(): Scheduler + @MainScheduler + fun mainScheduler(): Scheduler + val searchEngineProvider: SearchEngineProvider + val proxyUtils: com.lin.magic.utils.ProxyUtils + val textReflowJs: TextReflow + val invertPageJs: InvertPage + val setMetaViewport: SetMetaViewport + val homePageFactory: HomePageFactory + val abpBlockerManager: AbpBlockerManager + val noopBlocker: NoOpAdBlocker + val dialogBuilder: LightningDialogBuilder + val networkConnectivityModel: NetworkConnectivityModel + val faviconModel: FaviconModel + val webRtcPermissionsModel: WebRtcPermissionsModel + val abpUserRules: AbpUserRules + val downloadHandler: com.lin.magic.download.DownloadHandler + val downloadManager: DownloadManager + val downloadsRepository: DownloadsRepository + var tabsManager: TabsManager + var clipboardManager: ClipboardManager + +} + + diff --git a/app/src/main/java/com/lin/magic/dialog/BrowserDialog.kt b/app/src/main/java/com/lin/magic/dialog/BrowserDialog.kt new file mode 100644 index 00000000..719dea15 --- /dev/null +++ b/app/src/main/java/com/lin/magic/dialog/BrowserDialog.kt @@ -0,0 +1,299 @@ +/* + * Copyright 7/31/2016 Anthony Restaino + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lin.magic.dialog + +import com.lin.magic.R +import com.lin.magic.list.RecyclerViewDialogItemAdapter +import com.lin.magic.list.RecyclerViewStringAdapter +import android.app.Dialog +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager.widget.ViewPager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import com.lin.magic.extensions.inflater +import com.lin.magic.extensions.onConfigurationChange +import com.lin.magic.extensions.resizeAndShow + +object BrowserDialog { + + @JvmStatic + fun show( + aContext: Context, + @StringRes title: Int, + vararg items: DialogItem + ) = show(aContext, aContext.getString(title), *items) + + fun showWithIcons(context: Context, title: String?, vararg items: DialogItem) { + val builder = MaterialAlertDialogBuilder(context) + + val layout = context.inflater.inflate(R.layout.list_dialog, null) + + val titleView = layout.findViewById(R.id.dialog_title) + val recyclerView = layout.findViewById(R.id.dialog_list) + + val itemList = items.filter(DialogItem::show) + + val adapter = RecyclerViewDialogItemAdapter(itemList) + + if (title?.isNotEmpty() == true) { + titleView.text = title + } + + recyclerView.apply { + this.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + this.adapter = adapter + setHasFixedSize(true) + } + + builder.setView(layout) + + val dialog = builder.resizeAndShow() + + adapter.onItemClickListener = { item -> + item.onClick() + dialog.dismiss() + } + } + + /** + * Show a singly selectable list of [DialogItem] with the provided [title]. All items will be + * shown, and the first [DialogItem] where [DialogItem.show] returns `true` will be + * the selected item when the dialog is shown. The dialog has an OK button which just dismisses + * the dialog. + */ + fun showListChoices(aContext: Context, @StringRes title: Int, vararg items: DialogItem) { + MaterialAlertDialogBuilder(aContext).apply { + setTitle(title) + + val choices = items.map { aContext.getString(it.title) }.toTypedArray() + val currentChoice = items.indexOfFirst(DialogItem::show) + + setSingleChoiceItems(choices, currentChoice) { _, which -> + items[which].onClick() + } + setPositiveButton(aContext.getString(R.string.action_ok), null) + }.resizeAndShow() + } + + @JvmStatic + fun show(aContext: Context, title: String?, vararg items: DialogItem) { + val builder = MaterialAlertDialogBuilder(aContext) + + val layout = aContext.inflater.inflate(R.layout.list_dialog, null) + + val titleView = layout.findViewById(R.id.dialog_title) + val recyclerView = layout.findViewById(R.id.dialog_list) + + val itemList = items.filter(DialogItem::show) + + val adapter = RecyclerViewStringAdapter(itemList, getTitle = { aContext.getString(this.title) }, getText = { this.text} ) + + if (title?.isNotEmpty() == true) { + titleView.text = title + } + + recyclerView.apply { + this.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + this.adapter = adapter + setHasFixedSize(true) + } + + builder.setView(layout) + + val dialog = builder.resizeAndShow() + + adapter.onItemClickListener = { item -> + item.onClick() + dialog.dismiss() + } + } + + + /** + * Build and show a tabbed dialog based on the provided parameters. + * + * @param aContext The activity requesting that dialog. + * @param aTitle The dialog title string resource id. + * @param aHideSingleTab Set to true to hide tab layout when a single tab is visible. + * @param aTabs Define our dialog's tabs. + */ + @JvmStatic + fun show(aContext: Context, @StringRes aTitle: Int, aHideSingleTab: Boolean, vararg aTabs: DialogTab) { + show(aContext,null, aContext.getString(aTitle), aHideSingleTab,*aTabs) + } + + /** + * Build and show a tabbed dialog based on the provided parameters. + * + * @param aContext The activity requesting that dialog. + * @param aTitle The dialog title. + * @param aHideSingleTab Set to true to hide tab layout when a single tab is visible. + * @param aTabs Define our dialog's tabs. + */ + @JvmStatic + fun show(aContext: Context, aIcon: Drawable?, aTitle: String?, aHideSingleTab: Boolean, vararg aTabs: DialogTab) { + val builder = MaterialAlertDialogBuilder(aContext) + + // Inflate our layout + val layout = aContext.inflater.inflate(R.layout.dialog_tabs, null) + // Fetch the view we will need to use + val titleView = layout.findViewById(R.id.dialog_title) + val tabLayout = layout.findViewById(R.id.dialog_tab_layout) + val pager = layout.findViewById(R.id.dialog_viewpager) + + // Filter out invisible tabs + val tabList = aTabs.filter(DialogTab::show) + // Hide our tab layout out if needed + tabLayout.isVisible = !(aHideSingleTab && tabList.count() == 1) + // Create our dialog now as our adapter needs it + val dialog = builder.create() + // Set dialog title + if (aTitle?.isNotEmpty() == true) { + if (titleView!=null) { + // Use custom title if provided in our layout + titleView.text = aTitle + } else { + // Otherwise you standard dialog title + dialog.setTitle(aTitle) + } + } else { + titleView?.isVisible = false + } + // Create our adapter which will be creating our tabs content + pager.adapter = TabsPagerAdapter(aContext, dialog, tabList) + // Hook-in our adapter with our tab layout + tabLayout.setupWithViewPager(pager) + // Add icons to our tabs + var i: Int = 0 + tabList.forEach { + if (it.icon!=0) { + tabLayout.getTabAt(i)?.setIcon(it.icon) + } + i++ + } + // Our layout is setup, just hook it to our dialog + dialog.setView(layout) + setDialogSize(aContext, dialog) + dialog.setIcon(aIcon) + dialog.show() + //builder.resizeAndShow() + + // We want our dialog to close after a configuration change since the resizing is not working properly. + // It seems AlertDialog was never designed to handle screen rotation properly + // TODO: Instead of that workaround, find a way to resize our dialogs properly after screen rotation + // See: https://github.com/Slion/Magic/issues/437 + layout.onConfigurationChange { dialog.dismiss() } + } + + + @JvmStatic + fun showPositiveNegativeDialog( + aContext: Context, + @StringRes title: Int, + @StringRes message: Int, + messageArguments: Array? = null, + positiveButton: DialogItem, + negativeButton: DialogItem, + onCancel: () -> Unit + ) { + val messageValue = if (messageArguments != null) { + aContext.getString(message, *messageArguments) + } else { + aContext.getString(message) + } + MaterialAlertDialogBuilder(aContext).apply { + setTitle(title) + setMessage(messageValue) + setOnCancelListener { onCancel() } + setPositiveButton(positiveButton.title) { _, _ -> positiveButton.onClick() } + setNegativeButton(negativeButton.title) { _, _ -> negativeButton.onClick() } + }.resizeAndShow() + } + + @JvmStatic + fun showEditText( + aContext: Context, + @StringRes title: Int, + @StringRes hint: Int, + @StringRes action: Int, + textInputListener: (String) -> Unit + ) = showEditText(aContext, title, hint, null, action, textInputListener) + + @JvmStatic + fun showEditText( + aContext: Context, + @StringRes title: Int, + @StringRes hint: Int, + currentText: String?, + @StringRes action: Int, + textInputListener: (String) -> Unit + ) { + val layout = LayoutInflater.from(aContext).inflate(R.layout.dialog_edit_text, null) + val editText = layout.findViewById(R.id.dialog_edit_text) + + editText.setHint(hint) + if (currentText != null) { + editText.setText(currentText) + } + + val dialog = MaterialAlertDialogBuilder(aContext) + .setTitle(title) + .setView(layout) + .setPositiveButton(action + ) { _, _ -> textInputListener(editText.text.toString()) } + .resizeAndShow() + + // Discard it on screen rotation as it's broken anyway + layout.onConfigurationChange { dialog.dismiss() } + } + + @JvmStatic + fun setDialogSize(context: Context, dialog: Dialog) { + + // SL: That was really dumb so we comment it out + + /* + val padding = context.dimen(R.dimen.dialog_padding) + val screenSize = DeviceUtils.getScreenWidth(context) + if (maxWidth > screenSize - 2 * padding) { + maxWidth = screenSize - 2 * padding + }*/ + + //dialog.window?.setLayout(maxWidth, ViewGroup.LayoutParams.WRAP_CONTENT) + + //var maxHeight = context.dimen(R.dimen.dialog_max_height) + //dialog.window?.setLayout(dialog.window?.attributes!!.width, maxHeight) + } + + /** + * Show the custom dialog with the custom builder arguments applied. + */ + fun showCustomDialog(aContext: Context, block: MaterialAlertDialogBuilder.(Context) -> Unit) : Dialog { + MaterialAlertDialogBuilder(aContext).apply { + block(aContext) + return resizeAndShow() + } + } + +} diff --git a/app/src/main/java/com/lin/magic/dialog/DialogItem.kt b/app/src/main/java/com/lin/magic/dialog/DialogItem.kt new file mode 100644 index 00000000..5b701fcd --- /dev/null +++ b/app/src/main/java/com/lin/magic/dialog/DialogItem.kt @@ -0,0 +1,24 @@ +package com.lin.magic.dialog + +import android.graphics.drawable.Drawable +import androidx.annotation.ColorInt +import androidx.annotation.StringRes + + +/** + * Our dialog item features an [icon], a [title], a secondary [text], an [onClick] callback + * and a [show] boolean condition to conveniently control its visibility. + */ +class DialogItem( + val icon: Drawable? = null, + @param:ColorInt + val colorTint: Int? = null, + @param:StringRes + val title: Int, + val text: String? = null, + val show: Boolean = true, + // TODO: return boolean that tells if the dialog needs to be dismissed + private val onClick: () -> Unit +) { + fun onClick() = onClick.invoke() +} diff --git a/app/src/main/java/com/lin/magic/dialog/DialogTab.kt b/app/src/main/java/com/lin/magic/dialog/DialogTab.kt new file mode 100644 index 00000000..ee051d4e --- /dev/null +++ b/app/src/main/java/com/lin/magic/dialog/DialogTab.kt @@ -0,0 +1,25 @@ +package com.lin.magic.dialog + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + + +/** + * Define a tab in our dialog + * + * @param icon Drawable resource identifier. Will be used to set this tab icon. + * @param title This tab text title. + * @param show Tells if this tab should be visible. + * @param items List of items used to populate this tab content view. + */ +class DialogTab( + @DrawableRes + val icon: Int = 0, + @param:StringRes + val title: Int = 0, + val show: Boolean = true, + vararg items: DialogItem +) { + // Apparently that's needed for variable argument list + val iItems = items +} diff --git a/app/src/main/java/com/lin/magic/dialog/LightningDialogBuilder.kt b/app/src/main/java/com/lin/magic/dialog/LightningDialogBuilder.kt new file mode 100644 index 00000000..16c56b54 --- /dev/null +++ b/app/src/main/java/com/lin/magic/dialog/LightningDialogBuilder.kt @@ -0,0 +1,504 @@ +package com.lin.magic.dialog + +import com.lin.magic.activity.MainActivity +import com.lin.magic.R +import com.lin.magic.browser.WebBrowser +import com.lin.magic.database.Bookmark +import com.lin.magic.database.asFolder +import com.lin.magic.database.bookmark.BookmarkRepository +import com.lin.magic.database.downloads.DownloadsRepository +import com.lin.magic.database.history.HistoryRepository +import com.lin.magic.di.DatabaseScheduler +import com.lin.magic.di.MainScheduler +import com.lin.magic.extensions.* +import com.lin.magic.html.bookmark.BookmarkPageFactory +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.isBookmarkUrl +import android.Manifest +import android.app.Activity +import android.content.ClipboardManager +import android.content.pm.PackageManager +import android.os.Build +import android.view.View +import android.webkit.MimeTypeMap +import android.webkit.URLUtil +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.EditText +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +//import com.anthonycr.grant.PermissionsManager +//import com.anthonycr.grant.PermissionsResultAction +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.Reusable +import com.lin.magic.extensions.copyToClipboard +import com.lin.magic.extensions.onConfigurationChange +import com.lin.magic.extensions.onFocusGained +import com.lin.magic.extensions.resizeAndShow +import com.lin.magic.extensions.snackbar +import com.lin.magic.extensions.toast +import com.lin.magic.utils.shareUrl +import io.reactivex.Scheduler +import io.reactivex.rxkotlin.subscribeBy +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +/** + * A builder of various dialogs. + */ +@Reusable +class LightningDialogBuilder @Inject constructor( + private val bookmarkManager: BookmarkRepository, + private val downloadsModel: DownloadsRepository, + private val historyModel: HistoryRepository, + private val userPreferences: UserPreferences, + private val downloadHandler: com.lin.magic.download.DownloadHandler, + private val clipboardManager: ClipboardManager, + @DatabaseScheduler private val databaseScheduler: Scheduler, + @MainScheduler private val mainScheduler: Scheduler +) { + + enum class NewTab { + FOREGROUND, + BACKGROUND, + INCOGNITO + } + + + /** + * Show the appropriated dialog for the long pressed link. + * SL: Not used since we don't have a download list anymore. + * + * @param activity used to show the dialog + * @param url the long pressed url + */ + // TODO allow individual downloads to be deleted. + fun showLongPressedDialogForDownloadUrl( + activity: Activity, + webBrowser: WebBrowser, + url: String + ) = + BrowserDialog.show(activity, R.string.action_downloads, + DialogItem(title = R.string.dialog_delete_all_downloads) { + downloadsModel.deleteAllDownloads() + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe(webBrowser::handleDownloadDeleted) + }) + + /** + * Show the appropriated dialog for the long pressed link. It means that we try to understand + * if the link is relative to a bookmark or is just a folder. + * + * @param activity used to show the dialog + * @param url the long pressed url + */ + fun showLongPressedDialogForBookmarkUrl( + activity: Activity, + webBrowser: WebBrowser, + url: String + ) { + if (url.isBookmarkUrl()) { + // TODO hacky, make a better bookmark mechanism in the future + val uri = url.toUri() + val filename = requireNotNull(uri.lastPathSegment) { "Last segment should always exist for bookmark file" } + val folderTitle = filename.substring(0, filename.length - BookmarkPageFactory.FILENAME.length - 1) + showBookmarkFolderLongPressedDialog(activity, webBrowser, folderTitle.asFolder()) + } else { + bookmarkManager.findBookmarkForUrl(url) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { historyItem -> + // TODO: 6/14/17 figure out solution to case where slashes get appended to root urls causing the item to not exist + showLongPressedDialogForBookmarkUrl(activity, webBrowser, historyItem) + } + } + } + + /** + * Show bookmark context menu. + */ + fun showLongPressedDialogForBookmarkUrl( + activity: Activity, + webBrowser: WebBrowser, + entry: Bookmark.Entry + ) = + BrowserDialog.show( + activity, null, "", false, DialogTab( + show = true, icon = R.drawable.ic_bookmark, title = R.string.dialog_title_bookmark, items = arrayOf( + DialogItem(title = R.string.dialog_open_new_tab) { + webBrowser.handleNewTab(NewTab.FOREGROUND, entry.url) + }, + DialogItem(title = R.string.dialog_open_background_tab) { + webBrowser.handleNewTab(NewTab.BACKGROUND, entry.url) + }, + DialogItem( + title = R.string.dialog_open_incognito_tab, + show = activity is MainActivity + ) { + webBrowser.handleNewTab(NewTab.INCOGNITO, entry.url) + }, + DialogItem(title = R.string.action_share) { + activity.shareUrl(entry.url, entry.title) + }, + DialogItem(title = R.string.dialog_copy_link) { + clipboardManager.copyToClipboard(entry.url) + }, + DialogItem(title = R.string.dialog_remove_bookmark) { + bookmarkManager.deleteBookmark(entry) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { success -> + if (success) { + webBrowser.handleBookmarkDeleted(entry) + } + } + }, + DialogItem(title = R.string.dialog_edit_bookmark) { + showEditBookmarkDialog(activity, webBrowser, entry) + }) + ) + ) + + /** + * Show the add bookmark dialog. Shows a dialog with the title and URL pre-populated. + */ + fun showAddBookmarkDialog( + activity: Activity, + webBrowser: WebBrowser, + entry: Bookmark.Entry + ) { + val editBookmarkDialog = MaterialAlertDialogBuilder(activity) + editBookmarkDialog.setTitle(R.string.action_add_bookmark) + val dialogLayout = View.inflate(activity, R.layout.dialog_edit_bookmark, null) + val getTitle = dialogLayout.findViewById(R.id.bookmark_title) + getTitle.setText(entry.title) + val getUrl = dialogLayout.findViewById(R.id.bookmark_url) + getUrl.setText(entry.url) + val getFolder = dialogLayout.findViewById(R.id.bookmark_folder) + getFolder.setHint(R.string.folder) + getFolder.setText(entry.folder.title) + + val ignored = bookmarkManager.getFolderNames() + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { folders -> + val suggestionsAdapter = ArrayAdapter(activity, + android.R.layout.simple_dropdown_item_1line, folders) + getFolder.threshold = 1 + getFolder.onFocusGained { getFolder.showDropDown(); mainScheduler.scheduleDirect{getFolder.selectAll()} } + getFolder.setAdapter(suggestionsAdapter) + editBookmarkDialog.setView(dialogLayout) + editBookmarkDialog.setPositiveButton(activity.getString(R.string.action_ok)) { _, _ -> + val folder = getFolder.text.toString().asFolder() + // We need to query bookmarks in destination folder to be able to count them and set our new bookmark position + bookmarkManager.getBookmarksFromFolderSorted(folder.title).subscribeBy( + onSuccess = { + val editedItem = Bookmark.Entry( + title = getTitle.text.toString(), + url = getUrl.text.toString(), + folder = folder, + // Append new bookmark to existing ones by setting its position properly + position = it.count() + ) + bookmarkManager.addBookmarkIfNotExists(editedItem) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribeBy( + onSuccess = { success -> + if (success) { + webBrowser.handleBookmarksChange() + activity.toast(R.string.message_bookmark_added) + } else { + activity.toast(R.string.message_bookmark_not_added) + } + } + ) + } + ) + } + editBookmarkDialog.setNegativeButton(R.string.action_cancel) { _, _ -> } + editBookmarkDialog.resizeAndShow() + } + } + + private fun showEditBookmarkDialog( + activity: Activity, + webBrowser: WebBrowser, + entry: Bookmark.Entry + ) { + val editBookmarkDialog = MaterialAlertDialogBuilder(activity) + editBookmarkDialog.setTitle(R.string.title_edit_bookmark) + val layout = View.inflate(activity, R.layout.dialog_edit_bookmark, null) + val getTitle = layout.findViewById(R.id.bookmark_title) + getTitle.setText(entry.title) + val getUrl = layout.findViewById(R.id.bookmark_url) + getUrl.setText(entry.url) + val getFolder = layout.findViewById(R.id.bookmark_folder) + getFolder.setHint(R.string.folder) + getFolder.setText(entry.folder.title) + + val ignored = bookmarkManager.getFolderNames() + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { folders -> + val suggestionsAdapter = ArrayAdapter(activity, + android.R.layout.simple_dropdown_item_1line, folders) + getFolder.threshold = 1 + getFolder.onFocusGained { getFolder.showDropDown(); mainScheduler.scheduleDirect{getFolder.selectAll()} } + getFolder.setAdapter(suggestionsAdapter) + editBookmarkDialog.setView(layout) + editBookmarkDialog.setPositiveButton(activity.getString(R.string.action_ok)) { _, _ -> + val folder = getFolder.text.toString().asFolder() + if (folder.title != entry.folder.title) { + // We moved to a new folder we need to adjust our position then + bookmarkManager.getBookmarksFromFolderSorted(folder.title).subscribeBy( + onSuccess = { + val editedItem = Bookmark.Entry( + title = getTitle.text.toString(), + url = getUrl.text.toString(), + folder = folder, + position = it.count() + ) + bookmarkManager.editBookmark(entry, editedItem) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe(webBrowser::handleBookmarksChange) + } + ) + } else { + // We remain in the same folder just use existing position then + val editedItem = Bookmark.Entry( + title = getTitle.text.toString(), + url = getUrl.text.toString(), + folder = folder, + position = entry.position + ) + bookmarkManager.editBookmark(entry, editedItem) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe(webBrowser::handleBookmarksChange) + } + } + val dialog = editBookmarkDialog.resizeAndShow() + // Discard it on screen rotation as it's broken anyway + layout.onConfigurationChange { dialog.dismiss() } + } + } + + fun showBookmarkFolderLongPressedDialog( + activity: Activity, + webBrowser: WebBrowser, + folder: Bookmark.Folder + ) = + BrowserDialog.show( + activity, null, "", false, DialogTab( + show = true, icon = R.drawable.ic_folder, title = R.string.action_folder, items = arrayOf( + DialogItem(title = R.string.dialog_rename_folder) { + showRenameFolderDialog(activity, webBrowser, folder) + }, + DialogItem(title = R.string.dialog_remove_folder) { + bookmarkManager.deleteFolder(folder.title) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe { + webBrowser.handleBookmarkDeleted(folder) + } + }) + ) + ) + + private fun showRenameFolderDialog( + activity: Activity, + webBrowser: WebBrowser, + folder: Bookmark.Folder + ) = + BrowserDialog.showEditText( + activity, + R.string.title_rename_folder, + R.string.hint_title, + folder.title, + R.string.action_ok + ) { text -> + if (text.isNotBlank()) { + val oldTitle = folder.title + bookmarkManager.renameFolder(oldTitle, text) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe(webBrowser::handleBookmarksChange) + } + } + + /** + * Menu shown when doing a long press on an history list item. + */ + fun showLongPressedHistoryLinkDialog( + activity: Activity, + webBrowser: WebBrowser, + url: String + ) = + BrowserDialog.show( + activity, null, "", false, DialogTab( + show = true, icon = R.drawable.ic_history, title = R.string.action_history, items = arrayOf( + DialogItem(title = R.string.dialog_open_new_tab) { + webBrowser.handleNewTab(NewTab.FOREGROUND, url) + }, + DialogItem(title = R.string.dialog_open_background_tab) { + webBrowser.handleNewTab(NewTab.BACKGROUND, url) + }, + DialogItem( + title = R.string.dialog_open_incognito_tab, + show = activity is MainActivity + ) { + webBrowser.handleNewTab(NewTab.INCOGNITO, url) + }, + DialogItem(title = R.string.action_share) { + activity.shareUrl(url, null) + }, + DialogItem(title = R.string.dialog_copy_link) { + clipboardManager.copyToClipboard(url) + }, + DialogItem(title = R.string.dialog_remove_from_history) { + historyModel.deleteHistoryEntry(url) + .subscribeOn(databaseScheduler) + .observeOn(mainScheduler) + .subscribe(webBrowser::handleHistoryChange) + }) + ) + ) + + /** + * Show a dialog allowing the user to action either a link or an image. + */ + fun showLongPressLinkImageDialog( + activity: Activity, + webBrowser: WebBrowser, + linkUrl: String, + imageUrl: String, + text: String?, + userAgent: String, + showLinkTab: Boolean, + showImageTab: Boolean + ) = + BrowserDialog.show( + activity, null, "", false, + //Link tab + DialogTab(show = showLinkTab, icon = R.drawable.ic_link, title = R.string.button_link, items = arrayOf(DialogItem(title = R.string.dialog_open_new_tab) { + webBrowser.handleNewTab(NewTab.FOREGROUND, linkUrl) + }, + DialogItem(title = R.string.dialog_open_background_tab) { + webBrowser.handleNewTab(NewTab.BACKGROUND, linkUrl) + }, + DialogItem( + title = R.string.dialog_open_incognito_tab, + show = activity is MainActivity + ) { + webBrowser.handleNewTab(NewTab.INCOGNITO, linkUrl) + }, + DialogItem(title = R.string.action_share) { + activity.shareUrl(linkUrl, null) + }, + // Show copy text dialog item if we have some text + DialogItem(title = R.string.dialog_copy_text, show = !text.isNullOrEmpty()) { + if (!text.isNullOrEmpty()) { + clipboardManager.copyToClipboard(text) + activity.snackbar(R.string.message_text_copied) + } + }, + // Show copy link URL last + DialogItem(title = R.string.dialog_copy_link, text = linkUrl) { + clipboardManager.copyToClipboard(linkUrl) + activity.snackbar(R.string.message_link_copied) + } + )), + // Image tab + DialogTab(show = showImageTab, icon = R.drawable.ic_image, title = R.string.button_image, + items = arrayOf(DialogItem(title = R.string.dialog_open_new_tab) { + webBrowser.handleNewTab(NewTab.FOREGROUND, imageUrl) + }, + DialogItem(title = R.string.dialog_open_background_tab) { + webBrowser.handleNewTab(NewTab.BACKGROUND, imageUrl) + }, + DialogItem( + title = R.string.dialog_open_incognito_tab, + show = activity is MainActivity + ) { + webBrowser.handleNewTab(NewTab.INCOGNITO, imageUrl) + }, + DialogItem(title = R.string.action_share) { + activity.shareUrl(imageUrl, null) + }, + DialogItem( + title = R.string.action_download, + // Do not show download option for data URL as we don't support that for now + show = !URLUtil.isDataUrl(imageUrl) + ) { + Timber.d("Try download image: $imageUrl") + + fun doDownload() { + Timber.d("doDownload") + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(imageUrl).lowercase(Locale.ROOT)) + // Not sure why we should use PNG by default though. + // TODO: I think we have some code somewhere that can download something and then check its mime type from its content. + downloadHandler.onDownloadStart( + activity, userPreferences, imageUrl, userAgent, "attachment", mimeType + ?: "image/png", "" + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Those permissions are not needed anymore from Android 13 + doDownload() + } else { + // Ask for required permissions before starting our download + /*PermissionsManager.getInstance().requestPermissionsIfNecessaryForResult(activity, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), + object : + PermissionsResultAction() { + override fun onGranted() { + Timber.d("onGranted") + doDownload() + } + + override fun onDenied(permission: String) { + Timber.d("onDenied") + //TODO show message + } + })*/ + + val permissions = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) + if (permissions.all { ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED }) { + Timber.d("onGranted") + doDownload() + } else { + // 检查是否曾拒绝权限 + if (permissions.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }) { + Timber.d("onDenied") + //TODO show message + } else { + val requestPermissionsLauncher = (activity as ComponentActivity).registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permission -> + if (permission.all { it.value }) { + Timber.d("onGranted") + doDownload() + } else { + Timber.d("onDenied") + //TODO show message + } + } + requestPermissionsLauncher.launch(permissions) + } + } + } + }, + DialogItem(title = R.string.dialog_copy_link, text = imageUrl) { + clipboardManager.copyToClipboard(imageUrl) + activity.snackbar(R.string.message_link_copied) + } + )), + ) +} diff --git a/app/src/main/java/com/lin/magic/dialog/TabsPagerAdapter.kt b/app/src/main/java/com/lin/magic/dialog/TabsPagerAdapter.kt new file mode 100644 index 00000000..79e0afe4 --- /dev/null +++ b/app/src/main/java/com/lin/magic/dialog/TabsPagerAdapter.kt @@ -0,0 +1,78 @@ +package com.lin.magic.dialog + +import com.lin.magic.R +import com.lin.magic.extensions.inflater +import com.lin.magic.list.RecyclerViewStringAdapter +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager.widget.PagerAdapter + +/** + * Pager adapter instantiate pager items. + * + */ +class TabsPagerAdapter( + private val context: Context, + private val dialog: AlertDialog, + private val tabs: List +) : PagerAdapter() { + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + // Inflate our view from our layout definition + val view: View = context.inflater.inflate(R.layout.dialog_tab_list, container, false) + // Populate our list with our items + val recyclerView = view.findViewById(R.id.dialog_list) + val itemList = tabs[position].iItems.filter(DialogItem::show) + val adapter = RecyclerViewStringAdapter(itemList, getTitle = { context.getString(this.title) }, getText = {this.text}) + recyclerView.apply { + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + this.adapter = adapter + setHasFixedSize(true) + } + + adapter.onItemClickListener = { item -> + item.onClick() + dialog.dismiss() + } + + container.addView(view) + + return view + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + container.removeView(`object` as View); + } + + /** + * See: https://stackoverflow.com/questions/30995446/what-is-the-role-of-isviewfromobject-view-view-object-object-in-fragmentst + */ + override fun isViewFromObject(aView: View, aObject: Any): Boolean { + return aView === aObject + } + + override fun getCount(): Int { + return tabs.count() + } + + override fun getPageTitle(position: Int): CharSequence { + if (tabs[position].title == 0) { + return "" + } + return context.getString(tabs[position].title) + } + + /** + * Convert zero-based numbering of tabs into readable numbering of tabs starting at 1. + * + * @param position - Zero-based tab position + * @return Readable tab position + */ + private fun getReadableTabPosition(position: Int): Int { + return position + 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/download/DownloadHandler.java b/app/src/main/java/com/lin/magic/download/DownloadHandler.java new file mode 100644 index 00000000..c703644b --- /dev/null +++ b/app/src/main/java/com/lin/magic/download/DownloadHandler.java @@ -0,0 +1,341 @@ +/* + * Copyright 2014 A.C.R. Development + */ +package com.lin.magic.download; + +import android.app.Activity; +import android.app.Dialog; +import android.app.DownloadManager; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Environment; +import android.text.TextUtils; +import android.webkit.CookieManager; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.IOException; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.lin.magic.BuildConfig; +import com.lin.magic.activity.MainActivity; +import com.lin.magic.R; +import com.lin.magic.activity.WebBrowserActivity; +import com.lin.magic.browser.WebBrowser; +import com.lin.magic.database.downloads.DownloadEntry; +import com.lin.magic.database.downloads.DownloadsRepository; +import com.lin.magic.di.DatabaseScheduler; +import com.lin.magic.di.MainScheduler; +import com.lin.magic.di.NetworkScheduler; +import com.lin.magic.dialog.BrowserDialog; +import com.lin.magic.constant.Constants; +import com.lin.magic.settings.preferences.UserPreferences; +import com.lin.magic.utils.FileUtils; +import com.lin.magic.utils.Utils; +import com.lin.magic.view.WebPageTab; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import io.reactivex.Scheduler; +import io.reactivex.disposables.Disposable; +import timber.log.Timber; + +import static com.lin.magic.utils.UrlUtils.guessFileName; + +/** + * Handle download requests + */ +@Singleton +public class DownloadHandler { + private static final String COOKIE_REQUEST_HEADER = "Cookie"; + private static final String REFERER_REQUEST_HEADER = "Referer"; + private static final String USERAGENT_REQUEST_HEADER = "User-Agent"; + + private final DownloadsRepository downloadsRepository; + private final DownloadManager downloadManager; + private final Scheduler databaseScheduler; + private final Scheduler networkScheduler; + private final Scheduler mainScheduler; + + long iDownloadId = 0; + String iFilename=""; + + + @Inject + public DownloadHandler(DownloadsRepository downloadsRepository, + DownloadManager downloadManager, + @DatabaseScheduler Scheduler databaseScheduler, + @NetworkScheduler Scheduler networkScheduler, + @MainScheduler Scheduler mainScheduler) { + this.downloadsRepository = downloadsRepository; + this.downloadManager = downloadManager; + this.databaseScheduler = databaseScheduler; + this.networkScheduler = networkScheduler; + this.mainScheduler = mainScheduler; + } + + /** + * Notify the host application a download should be done, or that the data + * should be streamed if a streaming viewer is available. + * + * @param context The context in which the download was requested. + * @param url The full url to the content that should be downloaded + * @param userAgent User agent of the downloading application. + * @param contentDisposition Content-disposition http header, if present. + * @param mimeType The mimeType of the content reported by the server + * @param contentSize The size of the content + */ + public void onDownloadStart(@NonNull Activity context, @NonNull UserPreferences manager, @NonNull String url, String userAgent, + @Nullable String contentDisposition, String mimeType, @NonNull String contentSize) { + + Timber.d("DOWNLOAD: Trying to download from URL: %s", url); + Timber.d("DOWNLOAD: Content disposition: %s", contentDisposition); + Timber.d("DOWNLOAD: MimeType: %s", mimeType); + Timber.d("DOWNLOAD: User agent: %s", userAgent); + + // if we're dealing wih A/V content that's not explicitly marked + // for download, check if it's streamable. + if (contentDisposition == null + || !contentDisposition.regionMatches(true, 0, "attachment", 0, 10)) { + // query the package manager to see if there's a registered handler + // that matches. + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(Uri.parse(url), mimeType); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addCategory(Intent.CATEGORY_BROWSABLE); + intent.setComponent(null); + intent.setSelector(null); + ResolveInfo info = context.getPackageManager().resolveActivity(intent, + PackageManager.MATCH_DEFAULT_ONLY); + if (info != null) { + // If we resolved to ourselves, we don't want to attempt to + // load the url only to try and download it again. + if (BuildConfig.APPLICATION_ID.equals(info.activityInfo.packageName) + || MainActivity.class.getName().equals(info.activityInfo.name)) { + // someone (other than us) knows how to handle this mime + // type with this scheme, don't download. + try { + context.startActivity(intent); + return; + } catch (ActivityNotFoundException ex) { + // Best behavior is to fall back to a download in this + // case + } + } + } + } + onDownloadStartNoStream(context, manager, url, userAgent, contentDisposition, mimeType, contentSize); + } + + // This is to work around the fact that java.net.URI throws Exceptions + // instead of just encoding URL's properly + // Helper method for onDownloadStartNoStream + @NonNull + private static String encodePath(@NonNull String path) { + char[] chars = path.toCharArray(); + + boolean needed = false; + for (char c : chars) { + if (c == '[' || c == ']' || c == '|') { + needed = true; + break; + } + } + if (!needed) { + return path; + } + + StringBuilder sb = new StringBuilder(); + for (char c : chars) { + if (c == '[' || c == ']' || c == '|') { + sb.append('%'); + sb.append(Integer.toHexString(c)); + } else { + sb.append(c); + } + } + + return sb.toString(); + } + + /** + * Notify the host application a download should be done, even if there is a + * streaming viewer available for this type. + * + * @param context The context in which the download is requested. + * @param url The full url to the content that should be downloaded + * @param userAgent User agent of the downloading application. + * @param contentDisposition Content-disposition http header, if present. + * @param mimetype The mimetype of the content reported by the server + * @param contentSize The size of the content + */ + /* package */ + private void onDownloadStartNoStream(@NonNull final Activity context, @NonNull UserPreferences preferences, + @NonNull String url, String userAgent, + String contentDisposition, @Nullable String mimetype, @NonNull String contentSize) { + iFilename = guessFileName(url, contentDisposition, mimetype, null); + + WebBrowserActivity ba = (WebBrowserActivity)context; + + // Check to see if we have an SDCard + String status = Environment.getExternalStorageState(); + if (!status.equals(Environment.MEDIA_MOUNTED)) { + int title; + String msg; + + // Check to see if the SDCard is busy, same as the music app + if (status.equals(Environment.MEDIA_SHARED)) { + msg = context.getString(R.string.download_sdcard_busy_dlg_msg); + title = R.string.download_sdcard_busy_dlg_title; + } else { + msg = context.getString(R.string.download_no_sdcard_dlg_msg); + title = R.string.download_no_sdcard_dlg_title; + } + + Dialog dialog = new MaterialAlertDialogBuilder(context).setTitle(title) + .setIcon(android.R.drawable.ic_dialog_alert).setMessage(msg) + .setPositiveButton(R.string.action_ok, null).show(); + BrowserDialog.setDialogSize(context, dialog); + return; + } + + // java.net.URI is a lot stricter than KURL so we have to encode some + // extra characters. Fix for b 2538060 and b 1634719 + WebAddress webAddress; + try { + webAddress = new WebAddress(url); + webAddress.setPath(encodePath(webAddress.getPath())); + } catch (Exception e) { + // This only happens for very bad urls, we want to catch the + // exception here + Timber.e(e, "Exception while trying to parse url '" + url + '\''); + ba.showSnackbar( R.string.problem_download); + return; + } + + String addressString = webAddress.toString(); + Uri uri = Uri.parse(addressString); + final DownloadManager.Request request; + try { + request = new DownloadManager.Request(uri); + } catch (IllegalArgumentException e) { + ba.showSnackbar( R.string.cannot_download); + return; + } + + // set downloaded file destination to /sdcard/Download. + // or, should it be set to one of several Environment.DIRECTORY* dirs + // depending on mimetype? + String location = preferences.getDownloadDirectory(); + //String location = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).getPath(); + location = FileUtils.addNecessarySlashes(location); + Uri downloadFolder = Uri.parse(location); + + if (!isWriteAccessAvailable(downloadFolder)) { + ba.showSnackbar( R.string.problem_location_download); + return; + } + String newMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(Utils.guessFileExtension(iFilename)); + Timber.d("New mimetype: %s", newMimeType); + request.setMimeType(newMimeType); + request.setDestinationUri(Uri.parse(Constants.FILE + location + iFilename)); + // let this downloaded file be scanned by MediaScanner - so that it can + // show up in Gallery app, for example. + request.setVisibleInDownloadsUi(true); + request.allowScanningByMediaScanner(); + request.setDescription(webAddress.getHost()); + // XXX: Have to use the old url since the cookies were stored using the + // old percent-encoded url. + String cookies = CookieManager.getInstance().getCookie(url); + request.addRequestHeader(COOKIE_REQUEST_HEADER, cookies); + request.addRequestHeader(REFERER_REQUEST_HEADER, url); + request.addRequestHeader(USERAGENT_REQUEST_HEADER, userAgent); + // We don't want to show the default download complete notification as it just opens our app when you click it + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE); + + //noinspection VariableNotUsedInsideIf + if (mimetype == null) { + Timber.d("Mimetype is null"); + if (TextUtils.isEmpty(addressString)) { + return; + } + // We must have long pressed on a link or image to download it. We + // are not sure of the mimetype in this case, so do a head request + final Disposable disposable = new FetchUrlMimeType(downloadManager, request, addressString, cookies, userAgent) + .create() + .subscribeOn(networkScheduler) + .observeOn(mainScheduler) + .subscribe(result -> { + switch (result.iCode) { + case FAILURE_ENQUEUE: + ba.showSnackbar(R.string.cannot_download); + break; + case FAILURE_LOCATION: + ba.showSnackbar( R.string.problem_location_download); + break; + case SUCCESS: + iDownloadId = result.iDownloadId; + iFilename = result.iFilename; + ba.showSnackbar( context.getString(R.string.download_pending) + '\n' + iFilename); + break; + } + }); + } else { + Timber.d("Valid mimetype, attempting to download"); + try { + iDownloadId = downloadManager.enqueue(request); + } catch (IllegalArgumentException e) { + // Probably got a bad URL or something + Timber.e(e,"Unable to enqueue request"); + ba.showSnackbar( R.string.cannot_download); + } catch (SecurityException e) { + // TODO write a download utility that downloads files rather than rely on the system + // because the system can only handle Environment.getExternal... as a path + ba.showSnackbar( R.string.problem_location_download); + } + ba.showSnackbar( context.getString(R.string.download_pending) + '\n' + iFilename); + } + + // save download in database + WebBrowser browserActivity = (WebBrowser) context; + WebPageTab view = browserActivity.getTabModel().getCurrentTab(); + + if (view != null && !view.isIncognito()) { + downloadsRepository.addDownloadIfNotExists(new DownloadEntry(url, iFilename, contentSize)) + .subscribeOn(databaseScheduler) + .subscribe(aBoolean -> { + if (!aBoolean) { + Timber.d("error saving download to database"); + } + }); + } + } + + private static boolean isWriteAccessAvailable(@NonNull Uri fileUri) { + if (fileUri.getPath() == null) { + return false; + } + File file = new File(fileUri.getPath()); + + if (!file.isDirectory() && !file.mkdirs()) { + return false; + } + + try { + if (file.createNewFile()) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + return true; + } catch (IOException ignored) { + return false; + } + } +} diff --git a/app/src/main/java/com/lin/magic/download/FetchUrlMimeType.java b/app/src/main/java/com/lin/magic/download/FetchUrlMimeType.java new file mode 100644 index 00000000..a217baf1 --- /dev/null +++ b/app/src/main/java/com/lin/magic/download/FetchUrlMimeType.java @@ -0,0 +1,140 @@ +/* + * Copyright 2014 A.C.R. Development + */ +package com.lin.magic.download; + +import android.app.DownloadManager; +import android.os.Environment; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; + +import com.lin.magic.utils.Utils; +import androidx.annotation.NonNull; +import io.reactivex.Single; + +import static com.lin.magic.utils.UrlUtils.guessFileName; + +/** + * This class is used to pull down the http headers of a given URL so that we + * can analyse the mimetype and make any correction needed before we give the + * URL to the download manager. This operation is needed when the user + * long-clicks on a link or image and we don't know the mimetype. If the user + * just clicks on the link, we will do the same steps of correcting the mimetype + * down in android.os.webkit.LoadListener rather than handling it here. + */ +class FetchUrlMimeType { + + private static final String TAG = "FetchUrlMimeType"; + + private final DownloadManager.Request mRequest; + private final DownloadManager mDownloadManager; + private final String mUri; + private final String mCookies; + private final String mUserAgent; + + public FetchUrlMimeType(DownloadManager downloadManager, + DownloadManager.Request request, + String uri, + String cookies, + String userAgent) { + mRequest = request; + mDownloadManager = downloadManager; + mUri = uri; + mCookies = cookies; + mUserAgent = userAgent; + } + + public Single create() { + return Single.create(emitter -> { + // User agent is likely to be null, though the AndroidHttpClient + // seems ok with that. + String mimeType = null; + String contentDisposition = null; + HttpURLConnection connection = null; + try { + URL url = new URL(mUri); + connection = (HttpURLConnection) url.openConnection(); + if (mCookies != null && !mCookies.isEmpty()) { + connection.addRequestProperty("Cookie", mCookies); + connection.setRequestProperty("User-Agent", mUserAgent); + } + connection.connect(); + // We could get a redirect here, but if we do lets let + // the download manager take care of it, and thus trust that + // the server sends the right mimetype + if (connection.getResponseCode() == 200) { + String header = connection.getHeaderField("Content-Type"); + if (header != null) { + mimeType = header; + final int semicolonIndex = mimeType.indexOf(';'); + if (semicolonIndex != -1) { + mimeType = mimeType.substring(0, semicolonIndex); + } + } + String contentDispositionHeader = connection.getHeaderField("Content-Disposition"); + if (contentDispositionHeader != null) { + contentDisposition = contentDispositionHeader; + } + } + } catch (@NonNull IllegalArgumentException | IOException ex) { + if (connection != null) + connection.disconnect(); + } finally { + if (connection != null) + connection.disconnect(); + } + + Result res = new Result(); + + if (mimeType != null) { + if (mimeType.equalsIgnoreCase("text/plain") + || mimeType.equalsIgnoreCase("application/octet-stream")) { + String newMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( + Utils.guessFileExtension(mUri)); + if (newMimeType != null) { + mRequest.setMimeType(newMimeType); + } + } + res.iFilename = guessFileName(mUri, contentDisposition, mimeType, null); + mRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, res.iFilename); + } + + // Start the download + try { + res.iDownloadId = mDownloadManager.enqueue(mRequest); + res.iCode = ResultCode.SUCCESS; + emitter.onSuccess(res); + } catch (IllegalArgumentException e) { + // Probably got a bad URL or something + Log.e(TAG, "Unable to enqueue request", e); + res.iCode = ResultCode.FAILURE_ENQUEUE; + emitter.onSuccess(res); + } catch (SecurityException e) { + // TODO write a download utility that downloads files rather than rely on the system + // because the system can only handle Environment.getExternal... as a path + res.iCode = ResultCode.FAILURE_LOCATION; + emitter.onSuccess(res); + } + }); + } + + enum ResultCode { + FAILURE_ENQUEUE, + FAILURE_LOCATION, + SUCCESS + } + + /** + * Our download results providing filename, response code and download ID if any. + */ + class Result { + String iFilename; + ResultCode iCode = ResultCode.FAILURE_ENQUEUE; + long iDownloadId = 0; + } + +} diff --git a/app/src/main/java/com/lin/magic/download/LightningDownloadListener.kt b/app/src/main/java/com/lin/magic/download/LightningDownloadListener.kt new file mode 100644 index 00000000..a0dc6dd8 --- /dev/null +++ b/app/src/main/java/com/lin/magic/download/LightningDownloadListener.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2014 A.C.R. Development + */ +package com.lin.magic.download + +import com.lin.magic.R +import com.lin.magic.activity.WebBrowserActivity +import com.lin.magic.database.downloads.DownloadsRepository +import com.lin.magic.di.HiltEntryPoint +import com.lin.magic.di.configPrefs +import com.lin.magic.dialog.BrowserDialog.setDialogSize +import com.lin.magic.extensions.KDuration +import com.lin.magic.extensions.makeSnackbar +import com.lin.magic.extensions.snackbar +import com.lin.magic.settings.preferences.UserPreferences +import com.lin.magic.utils.guessFileName +import android.Manifest +import android.app.Activity +import android.app.Dialog +import android.app.DownloadManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.text.format.Formatter +import android.view.Gravity +import android.webkit.DownloadListener +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +//import com.anthonycr.grant.PermissionsManager +//import com.anthonycr.grant.PermissionsResultAction +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.EntryPointAccessors +import com.lin.magic.app +import timber.log.Timber + +//@AndroidEntryPoint +class LightningDownloadListener //Injector.getInjector(context).inject(this); + (private val mActivity: Activity) : BroadcastReceiver(), + DownloadListener { + + // Could not get injection working in broadcast receiver + private val hiltEntryPoint = EntryPointAccessors.fromApplication(app, HiltEntryPoint::class.java) + + val userPreferences: UserPreferences = hiltEntryPoint.userPreferences + val downloadHandler: com.lin.magic.download.DownloadHandler = hiltEntryPoint.downloadHandler + val downloadManager: DownloadManager = hiltEntryPoint.downloadManager + val downloadsRepository: DownloadsRepository = hiltEntryPoint.downloadsRepository + + // From BroadcastReceiver + // We use this to receive download complete notifications + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) { + //Fetching the download id received with the broadcast + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) + //Checking if the received broadcast is for our enqueued download by matching download id + // TODO: what if we have multiple downloads going on? I doubt our architecture supports that properly for now. + if (downloadHandler.iDownloadId == id) { + + // Our download is complete check if it was a success + val q = DownloadManager.Query() + q.setFilterById(id) + val c = downloadManager.query(q) + var contentTitle = "" + var contentText: String? = "" + var success = false + if (c.moveToFirst()) { + val status = c.getInt(c.getColumnIndex(DownloadManager.COLUMN_STATUS)) + if (status == DownloadManager.STATUS_SUCCESSFUL) { + success = true + contentTitle = context.getString(R.string.download_complete) + val filePath = + c.getString(c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + contentText = + filePath.substring(filePath.lastIndexOf('/') + 1, filePath.length) + } + // Assume failure + //if (status == DownloadManager.STATUS_FAILED) { + // That stupidly returns "placeholder" on F(x)tec Pro1 + //filename = c.getString(c.getColumnIndex(DownloadManager.COLUMN_REASON)); + } + c.close() + + // Create system notification + // Passing a null intent in case of failure means nothing happens when user taps our notification + // User needs to dismiss it using swipe + var pendingIntent: PendingIntent? = null + var downloadsIntent: Intent? = null + if (!success) { + contentTitle = context.getString(R.string.download_failed) + contentText = downloadHandler.iFilename + } else { + // Create pending intent to open downloads folder when tapping notification + var flags = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Was needed for Android 12 + flags = PendingIntent.FLAG_IMMUTABLE + } + downloadsIntent = + com.lin.magic.utils.Utils.getIntentForDownloads(mActivity, userPreferences.downloadDirectory) + pendingIntent = PendingIntent.getActivity(mActivity, 0, downloadsIntent, flags) + } + val builder = + NotificationCompat.Builder(mActivity, (mActivity as WebBrowserActivity).CHANNEL_ID) + .setSmallIcon(R.drawable.ic_file_download) // TODO: different icon for failure? + .setContentTitle(contentTitle) // + .setContentText(contentText) // + .setPriority(NotificationCompat.PRIORITY_DEFAULT) // Set the intent that will fire when the user taps the notification + .setContentIntent(pendingIntent) + .setAutoCancel(true) + val notificationManager = NotificationManagerCompat.from(mActivity) + // notificationId is a unique int for each notification that you must define + notificationManager.notify(0, builder.build()) + + //Show a snackbar with a link to open the downloaded file + if (success) { + val i = downloadsIntent + mActivity.makeSnackbar( + contentTitle, + KDuration, + if (mActivity.configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM + ).setAction( + R.string.show + ) { context.startActivity(i) }.show() + } else { + mActivity.snackbar( + contentTitle, + if (mActivity.configPrefs.toolbarsBottom) Gravity.TOP else Gravity.BOTTOM + ) + } + } + } + } + + private fun getFileName(id: Long): String { + val q = DownloadManager.Query() + q.setFilterById(id) + val c = downloadManager.query(q) + var filename = "" + if (c.moveToFirst()) { + val status = c.getInt(c.getColumnIndex(DownloadManager.COLUMN_STATUS)) + if (status == DownloadManager.STATUS_SUCCESSFUL) { + val filePath = c.getString(c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + filename = filePath.substring(filePath.lastIndexOf('/') + 1, filePath.length) + } else if (status == DownloadManager.STATUS_FAILED) { + // That stupidly returns "placeholder" on F(x)tec Pro1 + //filename = c.getString(c.getColumnIndex(DownloadManager.COLUMN_REASON)); + filename = "Failed" + } + } + c.close() + return filename + } + + override fun onDownloadStart( + url: String, userAgent: String, + contentDisposition: String, mimetype: String, contentLength: Long + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // No permissions needed anymore from Android 13 + doDownloadStart(url, userAgent, contentDisposition, mimetype, contentLength) + } else { + /*PermissionsManager.getInstance().requestPermissionsIfNecessaryForResult(mActivity, arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), + object : PermissionsResultAction() { + override fun onGranted() { + doDownloadStart(url, userAgent, contentDisposition, mimetype, contentLength) + } + + override fun onDenied(permission: String) { + //TODO show message + } + })*/ + + val permissions = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) + if (permissions.all { ContextCompat.checkSelfPermission(mActivity, it) == PackageManager.PERMISSION_GRANTED }) { + doDownloadStart(url, userAgent, contentDisposition, mimetype, contentLength) + } else { + // 检查是否曾拒绝权限 + if (permissions.any { ActivityCompat.shouldShowRequestPermissionRationale(mActivity, it) }) { + //TODO show message + } else { + val requestPermissionsLauncher = (mActivity as ComponentActivity).registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permission -> + if (permission.all { it.value }) { + doDownloadStart(url, userAgent, contentDisposition, mimetype, contentLength) + } else { + //TODO show message + } + } + requestPermissionsLauncher.launch(permissions) + } + } + } + + + // Some download link spawn an empty tab, just close it then + if (mActivity is WebBrowserActivity) { + mActivity.closeCurrentTabIfEmpty() + } + } + + private fun doDownloadStart( + url: String, userAgent: String, + contentDisposition: String, mimetype: String, contentLength: Long + ) { + val fileName = guessFileName(url, contentDisposition, mimetype, null) + val downloadSize: String + downloadSize = if (contentLength > 0) { + Formatter.formatFileSize(mActivity, contentLength) + } else { + mActivity.getString(R.string.unknown_size) + } + val dialogClickListener = + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> downloadHandler.onDownloadStart( + mActivity, + userPreferences, + url, + userAgent, + contentDisposition, + mimetype, + downloadSize + ) + DialogInterface.BUTTON_NEGATIVE -> {} + } + } + val builder = MaterialAlertDialogBuilder(mActivity) // dialog + val message = mActivity.getString(R.string.dialog_download, downloadSize) + val dialog: Dialog = builder.setTitle(fileName) + .setMessage(message) + .setPositiveButton( + mActivity.resources.getString(R.string.action_download), + dialogClickListener + ) + .setNegativeButton( + mActivity.resources.getString(R.string.action_cancel), + dialogClickListener + ).show() + setDialogSize(mActivity, dialog) + Timber.d("Downloading: $fileName") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/download/WebAddress.java b/app/src/main/java/com/lin/magic/download/WebAddress.java new file mode 100644 index 00000000..b02dcc61 --- /dev/null +++ b/app/src/main/java/com/lin/magic/download/WebAddress.java @@ -0,0 +1,173 @@ +/* + * Copyright 2014 A.C.R. Development + */ +package com.lin.magic.download; + + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import static android.util.Patterns.GOOD_IRI_CHAR; + +/** + * Web Address Parser + *

+ * This is called WebAddress, rather than URL or URI, because it attempts to + * parse the stuff that a user will actually type into a browser address widget. + *

+ * Unlike java.net.uri, this parser will not choke on URIs missing schemes. It + * will only throw a ParseException if the input is really hosed. + *

+ * If given an https scheme but no port, fills in port + */ +class WebAddress { + + private String mScheme; + private String mHost; + private int mPort; + private String mPath; + private String mAuthInfo; + private static final int MATCH_GROUP_SCHEME = 1; + private static final int MATCH_GROUP_AUTHORITY = 2; + private static final int MATCH_GROUP_HOST = 3; + private static final int MATCH_GROUP_PORT = 4; + private static final int MATCH_GROUP_PATH = 5; + private static final Pattern sAddressPattern = Pattern.compile( + /* scheme */"(?:(http|https|file)://)?" + + /* authority */"(?:([-A-Za-z0-9$_.+!*'(),;?&=]+(?::[-A-Za-z0-9$_.+!*'(),;?&=]+)?)@)?" + + /* host */"([" + GOOD_IRI_CHAR + "%_-][" + GOOD_IRI_CHAR + "%_\\.-]*|\\[[0-9a-fA-F:\\.]+\\])?" + + /* port */"(?::([0-9]*))?" + + /* path */"(/?[^#]*)?" + + /* anchor */".*", Pattern.CASE_INSENSITIVE); + + /** + * Parses given URI-like string. + */ + public WebAddress(@Nullable String address) throws IllegalArgumentException { + + if (address == null) { + throw new IllegalArgumentException("address can't be null"); + } + + mScheme = ""; + mHost = ""; + mPort = -1; + mPath = "/"; + mAuthInfo = ""; + + Matcher m = sAddressPattern.matcher(address); + String t; + if (!m.matches()) { + throw new IllegalArgumentException("Parsing of address '" + address + "' failed"); + } + + t = m.group(MATCH_GROUP_SCHEME); + if (t != null) { + mScheme = t.toLowerCase(Locale.ROOT); + } + t = m.group(MATCH_GROUP_AUTHORITY); + if (t != null) { + mAuthInfo = t; + } + t = m.group(MATCH_GROUP_HOST); + if (t != null) { + mHost = t; + } + t = m.group(MATCH_GROUP_PORT); + if (t != null && !t.isEmpty()) { + // The ':' character is not returned by the regex. + try { + mPort = Integer.parseInt(t); + } catch (NumberFormatException ex) { + throw new RuntimeException("Parsing of port number failed", ex); + } + } + t = m.group(MATCH_GROUP_PATH); + if (t != null && !t.isEmpty()) { + /* + * handle busted myspace frontpage redirect with missing initial "/" + */ + if (t.charAt(0) == '/') { + mPath = t; + } else { + mPath = '/' + t; + } + } + + /* + * Get port from scheme or scheme from port, if necessary and possible + */ + if (mPort == 443 && mScheme != null && mScheme.isEmpty()) { + mScheme = "https"; + } else if (mPort == -1) { + if ("https".equals(mScheme)) { + mPort = 443; + } else { + mPort = 80; // default + } + } + if (mScheme != null && mScheme.isEmpty()) { + mScheme = "http"; + } + } + + @NonNull + @Override + public String toString() { + + String port = ""; + if ((mPort != 443 && "https".equals(mScheme)) || (mPort != 80 && "http".equals(mScheme))) { + port = ':' + Integer.toString(mPort); + } + String authInfo = ""; + if (!mAuthInfo.isEmpty()) { + authInfo = mAuthInfo + '@'; + } + + return mScheme + "://" + authInfo + mHost + port + mPath; + } + + public void setScheme(String scheme) { + mScheme = scheme; + } + + public String getScheme() { + return mScheme; + } + + public void setHost(@NonNull String host) { + mHost = host; + } + + public String getHost() { + return mHost; + } + + public void setPort(int port) { + mPort = port; + } + + public int getPort() { + return mPort; + } + + public void setPath(String path) { + mPath = path; + } + + public String getPath() { + return mPath; + } + + public void setAuthInfo(String authInfo) { + mAuthInfo = authInfo; + } + + public String getAuthInfo() { + return mAuthInfo; + } +} diff --git a/app/src/main/java/com/lin/magic/enums/CutoutMode.kt b/app/src/main/java/com/lin/magic/enums/CutoutMode.kt new file mode 100644 index 00000000..42c63c78 --- /dev/null +++ b/app/src/main/java/com/lin/magic/enums/CutoutMode.kt @@ -0,0 +1,29 @@ +package com.lin.magic.enums + +import com.lin.magic.settings.preferences.IntEnum +import android.view.WindowManager + +enum class CutoutMode(override val value: Int) : IntEnum { + /** + * See: [WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT] + */ + Default(0), + /** + * See: [WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES] + */ + ShortEdges(1), + /** + * See: [WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER] + */ + Never(2), + /** + * See: [WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS] + */ + Always(3) +} + +/* +enum class CutoutMode { + Default = 0, +} +*/ diff --git a/app/src/main/java/com/lin/magic/enums/HeaderInfo.kt b/app/src/main/java/com/lin/magic/enums/HeaderInfo.kt new file mode 100644 index 00000000..bb1f1605 --- /dev/null +++ b/app/src/main/java/com/lin/magic/enums/HeaderInfo.kt @@ -0,0 +1,19 @@ +package com.lin.magic.enums + +/** + * Define which information should be displayed in a header such as address bar, task bar and task switcher. + * + * NOTES: + * - Class name is referenced as string in our resources. + * - Enum string values are stored in preferences. + */ +enum class HeaderInfo { + Url, + ShortUrl, + Domain, + Title, + Session, + AppName +} + + diff --git a/app/src/main/java/com/lin/magic/enums/LayerType.kt b/app/src/main/java/com/lin/magic/enums/LayerType.kt new file mode 100644 index 00000000..99f7989e --- /dev/null +++ b/app/src/main/java/com/lin/magic/enums/LayerType.kt @@ -0,0 +1,25 @@ +package com.lin.magic.enums + + +import android.view.View +import com.lin.magic.settings.preferences.IntEnum + +/** + * View layer type as an enum so that we can conveniently use it as a preference + */ +enum class LayerType(override val value: Int) : IntEnum { + /** + * The view is rendered normally and is not backed by an off-screen buffer. This is the default behavior. + * Though in my experience this uses hardware acceleration too. + */ + None(View.LAYER_TYPE_NONE), + /** + * The view is rendered in software into a bitmap. + */ + Software(View.LAYER_TYPE_SOFTWARE), + /** + * The view is rendered in hardware into a hardware texture if the application is hardware accelerated. + */ + Hardware(View.LAYER_TYPE_HARDWARE) +} + diff --git a/app/src/main/java/com/lin/magic/enums/LogLevel.kt b/app/src/main/java/com/lin/magic/enums/LogLevel.kt new file mode 100644 index 00000000..aaad80ad --- /dev/null +++ b/app/src/main/java/com/lin/magic/enums/LogLevel.kt @@ -0,0 +1,21 @@ +package com.lin.magic.enums + +import com.lin.magic.settings.preferences.IntEnum +import android.util.Log + +/** + * Notably used to define what to do when there is a third-party associated with a web site. + * + * NOTE: Class name is referenced as strings in our resources. + */ +enum class LogLevel(override val value: Int) : + IntEnum { + VERBOSE(Log.VERBOSE), + DEBUG(Log.DEBUG), + INFO(Log.INFO), + WARN(Log.WARN), + ERROR(Log.ERROR), + ASSERT(Log.ASSERT) +} + + diff --git a/app/src/main/java/com/lin/magic/extensions/ActivityExtensions.kt b/app/src/main/java/com/lin/magic/extensions/ActivityExtensions.kt new file mode 100644 index 00000000..32712f9c --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/ActivityExtensions.kt @@ -0,0 +1,117 @@ +/* + * Copyright © 2020-2021 Stéphane Lenclud + */ + +@file:JvmName("ActivityExtensions") + +package com.lin.magic.extensions + +import com.lin.magic.R +import android.annotation.SuppressLint +import android.app.Activity +import android.app.ActivityManager +import android.os.Build +import android.view.Gravity +import android.view.View +import android.view.Window +import androidx.annotation.StringRes +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import com.lin.magic.app +import timber.log.Timber + + +// Define our snackbar popup duration +const val KDuration: Int = 4000; // Snackbar.LENGTH_LONG + +/** + * Displays a snackbar to the user with a [StringRes] message. + * + * NOTE: If there is an accessibility manager enabled on + * the device, such as LastPass, then the snackbar animations + * will not work. + * + * @param resource the string resource to display to the user. + */ +fun Activity.snackbar(@StringRes resource: Int, aGravity: Int = Gravity.BOTTOM) { + makeSnackbar(getString(resource), KDuration, aGravity).show() +} + +/** + * Display a snackbar to the user with a [String] message. + * + * @param message the message to display to the user. + * @see snackbar + */ +fun Activity.snackbar(message: String, aGravity: Int = Gravity.BOTTOM) { + makeSnackbar(message, KDuration, aGravity).show() +} + +/** + * + */ +@SuppressLint("WrongConstant") +fun Activity.makeSnackbar(message: String, aDuration: Int, aGravity: Int): Snackbar { + var view = findViewById(R.id.web_view_frame) + if (view == null) { + // We won't use gravity and we provide compatibility with previous implementation + view = findViewById(android.R.id.content) + return Snackbar.make(view, message, aDuration) + } else { + // Apply specified gravity before showing snackbar + val snackbar = Snackbar.make(view, message, aDuration) + //snackbar.setAnchorView(R.id.web_view_frame) + snackbar.animationMode = BaseTransientBottomBar.ANIMATION_MODE_FADE + val params = snackbar.view.layoutParams as CoordinatorLayout.LayoutParams + params.gravity = aGravity + if (aGravity==Gravity.TOP) { + // Move snackbar away from status bar + // That one works well it seems + //params.topMargin = Utils.dpToPx(90F) + } else { + // Make sure it is above rounded corner + // Ain't working on F(x)tec Pro1, weird... + //params.bottomMargin = Utils.dpToPx(90F) + } + snackbar.view.layoutParams = params + + return snackbar; + } +} + +/** + * + */ +fun Window.setStatusBarIconsColor(dark: Boolean) +{ + // That's the new API we should use but we had no joy with it on Honor Magic V2 + //WindowCompat.getInsetsController(this, decorView).isAppearanceLightNavigationBars = !dark + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (dark) { + decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else { + decorView.systemUiVisibility = decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } + } +} + + +/** + * Change the text displayed in task switcher or window title on WSA + */ +fun Activity.setTaskLabel(aLabel: String?) { + Timber.v("setTaskLabel: $aLabel") + if (aLabel==null) { + return + } + + if (!app.incognito) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setTaskDescription(ActivityManager.TaskDescription.Builder().setLabel(aLabel).build()) + } else { + setTaskDescription(ActivityManager.TaskDescription(aLabel)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/extensions/AlertDialogExtensions.kt b/app/src/main/java/com/lin/magic/extensions/AlertDialogExtensions.kt new file mode 100644 index 00000000..55b3700b --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/AlertDialogExtensions.kt @@ -0,0 +1,30 @@ +package com.lin.magic.extensions + +import com.lin.magic.dialog.BrowserDialog +import android.app.Dialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** + * Show single choice items. + * + * @param items A list of items and their user readable string description. + * @param checkedItem The item that will be checked when the dialog is displayed. + * @param onClick Called when an item is clicked. The item clicked is provided. + */ +fun MaterialAlertDialogBuilder.withSingleChoiceItems( + items: List>, + checkedItem: T, + onClick: (T) -> Unit +) { + val checkedIndex = items.map(Pair::first).indexOf(checkedItem) + val titles = items.map(Pair::second).toTypedArray() + setSingleChoiceItems(titles, checkedIndex) { _, which -> + onClick(items[which].first) + } +} + +/** + * Ensures that the dialog is appropriately sized and displays it. + */ +@Suppress("NOTHING_TO_INLINE") +inline fun MaterialAlertDialogBuilder.resizeAndShow(): Dialog = show().also { BrowserDialog.setDialogSize(context, it) } diff --git a/app/src/main/java/com/lin/magic/extensions/AnyExtensions.kt b/app/src/main/java/com/lin/magic/extensions/AnyExtensions.kt new file mode 100644 index 00000000..20623e7d --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/AnyExtensions.kt @@ -0,0 +1,15 @@ +package com.lin.magic.extensions + +/** + * Identity Hash Code + */ +val Any.ihc : Int + get() = System.identityHashCode(this) + +/** + * Identity Hash Code as hexadecimal encoded string. + * Notably useful to use in logs for objects with multiple instances. + */ +val Any.ihs : String + get() = "%08X".format(ihc) + diff --git a/app/src/main/java/com/lin/magic/extensions/BitmapExtensions.kt b/app/src/main/java/com/lin/magic/extensions/BitmapExtensions.kt new file mode 100644 index 00000000..5b8faf59 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/BitmapExtensions.kt @@ -0,0 +1,71 @@ +package com.lin.magic.extensions + +import android.graphics.* +import androidx.core.graphics.createBitmap + + +/** + * Creates and returns a new favicon which is the same as the provided favicon but with horizontal + * and vertical padding of 4dp + * + * @return the padded bitmap. + */ +fun Bitmap.pad(): Bitmap { + // SL: Disabled that funny padding it would cause favicon from frozen tab to look even smaller somehow + return this; +} + +/* +fun Bitmap.pad(): Bitmap = let { + val padding = Utils.dpToPx(4f) + val width = it.width + padding + val height = it.height + padding + + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { + Canvas(this).apply { + drawARGB(0x00, 0x00, 0x00, 0x00) // this represents white color + drawBitmap(it, (padding / 2).toFloat(), (padding / 2).toFloat(), Paint(Paint.FILTER_BITMAP_FLAG)) + } + } +}*/ + +private val desaturatedPaint = Paint().apply { + colorFilter = ColorMatrixColorFilter(ColorMatrix().apply { + setSaturation(0.5f) + }) +} + + +/** + * Desaturates a [Bitmap] to 50% grayscale. Note that a new bitmap will be created. + */ +fun Bitmap.desaturate(): Bitmap = createBitmap(width, height).also { + Canvas(it).drawBitmap(this, 0f, 0f, desaturatedPaint) +} + +/** + * Return a new bitmap containing this bitmap with inverted colors. + * See: https://gist.github.com/moneytoo/87e3772c821cb1e86415 + */ +fun Bitmap.invert(): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint() + val matrixGrayscale = ColorMatrix() + matrixGrayscale.setSaturation(0f) + val matrixInvert = ColorMatrix() + matrixInvert.set( + floatArrayOf( + -1.0f, 0.0f, 0.0f, 0.0f, 255.0f, + 0.0f, -1.0f, 0.0f, 0.0f, 255.0f, + 0.0f, 0.0f, -1.0f, 0.0f, 255.0f, + 0.0f, 0.0f, 0.0f, 1.0f, 0.0f + ) + ) + matrixInvert.preConcat(matrixGrayscale) + val filter = ColorMatrixColorFilter(matrixInvert) + paint.colorFilter = filter + canvas.drawBitmap(this, 0f, 0f, paint) + return bitmap +} + diff --git a/app/src/main/java/com/lin/magic/extensions/CanvasExtensions.kt b/app/src/main/java/com/lin/magic/extensions/CanvasExtensions.kt new file mode 100644 index 00000000..49c7cdc2 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/CanvasExtensions.kt @@ -0,0 +1,47 @@ +package com.lin.magic.extensions + +import com.lin.magic.utils.Utils.mixTwoColors +import android.graphics.* +import kotlin.math.tan + +/** + * Draws the trapezoid background for the horizontal tabs on a canvas object using the specified + * color. + * + * @param backgroundColor the color to use to draw the tab + * @param withShadow true if the trapezoid should have a shadow, false otherwise. + */ +fun Canvas.drawTrapezoid(backgroundColor: Int, withShadow: Boolean) { + + val shadowColor = mixTwoColors(Color.BLACK, backgroundColor, 0.5f) + + val paint = Paint().apply { + color = backgroundColor + style = Paint.Style.FILL + // isFilterBitmap = true + isAntiAlias = true + isDither = true + if (withShadow) { + shader = LinearGradient( + 0f, 0.9f * height, 0f, height.toFloat(), + backgroundColor, shadowColor, + Shader.TileMode.CLAMP + ) + } + } + + // SL: use Math.PI / 3 to get our ugly trapezoid back + val radians = Math.PI / 2 + val base = (height / tan(radians)).toInt() + + val wallPath = Path().apply { + reset() + moveTo(0f, height.toFloat()) + lineTo(width.toFloat(), height.toFloat()) + lineTo((width - base).toFloat(), 0f) + lineTo(base.toFloat(), 0f) + close() + } + + drawPath(wallPath, paint) +} diff --git a/app/src/main/java/com/lin/magic/extensions/ClipboardManagerExtensions.kt b/app/src/main/java/com/lin/magic/extensions/ClipboardManagerExtensions.kt new file mode 100644 index 00000000..4c38981c --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/ClipboardManagerExtensions.kt @@ -0,0 +1,11 @@ +package com.lin.magic.extensions + +import android.content.ClipData +import android.content.ClipboardManager + +/** + * Copies the [text] to the clipboard with the label `URL`. + */ +fun ClipboardManager.copyToClipboard(text: String, label: String = "URL") { + setPrimaryClip(ClipData.newPlainText(label, text)) +} diff --git a/app/src/main/java/com/lin/magic/extensions/CloseableExtensions.kt b/app/src/main/java/com/lin/magic/extensions/CloseableExtensions.kt new file mode 100644 index 00000000..7142a6cf --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/CloseableExtensions.kt @@ -0,0 +1,16 @@ +package com.lin.magic.extensions + +import android.util.Log +import java.io.Closeable + +/** + * Close a [Closeable] and absorb any exceptions within [block], logging them when they occur. + */ +inline fun T.safeUse(block: (T) -> R): R? { + return try { + this.use(block) + } catch (throwable: Throwable) { + Log.e("Closeable", "Unable to parse results", throwable) + null + } +} diff --git a/app/src/main/java/com/lin/magic/extensions/ContextExtensions.kt b/app/src/main/java/com/lin/magic/extensions/ContextExtensions.kt new file mode 100644 index 00000000..fa1fd31f --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/ContextExtensions.kt @@ -0,0 +1,161 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.lin.magic.extensions + +// For comments links + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.LayoutInflater +import android.widget.Toast +import androidx.annotation.* +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.preference.PreferenceManager +import com.google.android.material.resources.MaterialAttributes +import com.lin.magic.R +import com.lin.magic.settings.Config +import timber.log.Timber +import java.util.* + + +/** + * Returns the dimension in pixels. + * + * @param dimenRes the dimension resource to fetch. + */ +inline fun Context.dimen(@DimenRes dimenRes: Int): Int = resources.getDimensionPixelSize(dimenRes) + +/** + * Returns the [ColorRes] as a [ColorInt] + */ +@ColorInt +inline fun Context.color(@ColorRes colorRes: Int): Int = ContextCompat.getColor(this, colorRes) + +/** + * Shows a toast with the provided [StringRes]. + */ +inline fun Context.toast(@StringRes stringRes: Int) = Toast.makeText(this, stringRes, Toast.LENGTH_SHORT).show() + +/** + * + */ +inline fun Context.toast(string: String) = Toast.makeText(this, string, Toast.LENGTH_SHORT).show() + +/** + * The [LayoutInflater] available on the [Context]. + */ +inline val Context.inflater: LayoutInflater + get() = LayoutInflater.from(this) + +/** + * Gets a drawable from the context. + */ +inline fun Context.drawable(@DrawableRes drawableRes: Int): Drawable = ContextCompat.getDrawable(this, drawableRes)!! + +/** + * The preferred locale of the user. + */ +val Context.preferredLocale: Locale + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + resources.configuration.locales[0] + } else { + @Suppress("DEPRECATION") + resources.configuration.locale + } + +@ColorInt +fun Context.attrColor( @AttrRes attrColor: Int): Int { + val typedArray = theme.obtainStyledAttributes(intArrayOf(attrColor)) + val textColor = typedArray.getColor(0, 0) + typedArray.recycle() + return textColor +} + + +/** + * See [PreferenceManager.getDefaultSharedPreferencesName] + */ +fun Context.portraitSharedPreferencesName(): String { + return packageName + "_preferences_portrait" +} + +/** + * See [PreferenceManager.getDefaultSharedPreferencesName] + */ +fun Context.landscapeSharedPreferencesName(): String { + return packageName + "_preferences_landscape" +} + +/** + * Allows us to have rich text and variable interpolation. + */ +fun Context.getText(@StringRes id: Int, vararg args: Any?): CharSequence? { + return ContextUtils.getText(this, id, *args) +} + +/** + * + */ +@SuppressLint("RestrictedApi") +fun Context.isLightTheme(): Boolean { + //Timber.v("isLight: $isLight") + return MaterialAttributes.resolveBoolean(this, R.attr.isLightTheme, true) +} + +/** + * + */ +fun Context.isDarkTheme(): Boolean { + return !isLightTheme() +} + +/** + * + */ +fun Context.createDefaultFavicon(): Bitmap { + Timber.v("createDefaultFavicon") + return getDrawable(R.drawable.ic_web, android.R.attr.state_enabled).toBitmap() +} + +/** + * Load drawable [aId] from resources using theme and apply given [aStates]. + * + * See drawable state: https://stackoverflow.com/questions/11943795 + */ +fun Context.getDrawable(@DrawableRes aId: Int, vararg aStates: Int): Drawable { + Timber.v("getDrawable") + return ResourcesCompat.getDrawable(resources, aId, theme)!!.apply{ state = intArrayOf(*aStates) } +} + +/** + * + */ +val Context.isPortrait: Boolean get() = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + +/** + * + */ +val Context.isLandscape: Boolean get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + +/** + * Provide the id of the current config + */ +val Context.configId: String get() { + + val rotation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + display?.rotation?.times(90) + } else { + (this as? Activity)?.windowManager?.defaultDisplay?.rotation?.times(90) + } + + return "${Config.filePrefix}${if (isLandscape) "landscape" else "portrait"}-$rotation-sw${resources.configuration.smallestScreenWidthDp}" +} + + + diff --git a/app/src/main/java/com/lin/magic/extensions/ContextUtils.java b/app/src/main/java/com/lin/magic/extensions/ContextUtils.java new file mode 100644 index 00000000..ab167b77 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/ContextUtils.java @@ -0,0 +1,33 @@ +package com.lin.magic.extensions; + + +import static androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT; +import static androidx.core.text.HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL; + +import android.content.Context; +import android.text.SpannedString; + +import androidx.annotation.StringRes; +import androidx.core.text.HtmlCompat; + +public class ContextUtils { + + + /** + * Allows us to have rich text and variable interpolation. + * New line and white space handling is a mess though. + * Kept it in Java cause we could not get varargs to work as we wanted in Kotlin. + * Modded from: https://stackoverflow.com/a/23562910/3969362 + * + * @param context + * @param id + * @param args + * @return + */ + public static CharSequence getText(Context context, @StringRes int id, Object... args) { + for (int i = 0; i < args.length; ++i) + args[i] = args[i] instanceof String ? HtmlCompat.toHtml(new SpannedString((String)args[i]),TO_HTML_PARAGRAPH_LINES_INDIVIDUAL) : args[i]; + return HtmlCompat.fromHtml(String.format(HtmlCompat.toHtml(new SpannedString(context.getText(id)),TO_HTML_PARAGRAPH_LINES_INDIVIDUAL), args),FROM_HTML_MODE_COMPACT); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/extensions/CursorExtensions.kt b/app/src/main/java/com/lin/magic/extensions/CursorExtensions.kt new file mode 100644 index 00000000..9366c7b7 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/CursorExtensions.kt @@ -0,0 +1,41 @@ +package com.lin.magic.extensions + +import android.database.Cursor + +/** + * Map the cursor to a [List] of [T], passing the cursor back into the [block] for convenience. + */ +inline fun Cursor.map(block: (Cursor) -> T): List { + val outputList = mutableListOf() + while (moveToNext()) { + outputList.add(block(this)) + } + return outputList +} + +/** + * Map the cursor to a [List] of [T], passing the cursor back into the [block] for convenience, and + * then closing the cursor upon return. + */ +inline fun Cursor.useMap(block: (Cursor) -> T): List { + use { + val outputList = mutableListOf() + while (moveToNext()) { + outputList.add(block(this)) + } + return outputList + } +} + + +/** + * Return the first element returned by this cursor as [T] or null if there were no elements in the + * cursor. + */ +inline fun Cursor.firstOrNullMap(block: (Cursor) -> T): T? { + return if (moveToFirst()) { + return block(this) + } else { + null + } +} diff --git a/app/src/main/java/com/lin/magic/extensions/DrawableExtensions.kt b/app/src/main/java/com/lin/magic/extensions/DrawableExtensions.kt new file mode 100644 index 00000000..d0068013 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/DrawableExtensions.kt @@ -0,0 +1,101 @@ +package com.lin.magic.extensions + +import android.graphics.Bitmap +import android.graphics.Bitmap.Config +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.StateListDrawable +import androidx.annotation.ColorInt +import androidx.annotation.Px +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import androidx.core.graphics.component1 +import androidx.core.graphics.component2 +import androidx.core.graphics.component3 +import androidx.core.graphics.component4 +import androidx.core.graphics.drawable.toBitmapOrNull +import java.lang.reflect.Method + +/** + * Tint a drawable with the provided [color], using [BlendModeCompat.SRC_IN]. + */ +fun Drawable.tint(@ColorInt color: Int) { + colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) +} + +/** + * Use non public method to extract drawable corresponding to given state. + * See: https://stackoverflow.com/a/26714363/3969362 + */ +fun StateListDrawable.drawableForStateOrNull(aState: Int) : Drawable? { + return try { + val getStateDrawableIndex: Method = StateListDrawable::class.java.getMethod("getStateDrawableIndex", IntArray::class.java) + val getStateDrawable: Method = StateListDrawable::class.java.getMethod("getStateDrawable", Int::class.javaPrimitiveType) + val index = getStateDrawableIndex.invoke(this, intArrayOf(aState)) as Int + getStateDrawable.invoke(this, index) as Drawable + } catch (ex :Exception) { + null + } +} + +/** + * Get drawable corresponding to given state or this. + */ +fun StateListDrawable.drawableForState(aState: Int) : Drawable { + drawableForStateOrNull(aState)?.let{ return it } + return this +} + + + +/** + * SL: Duplicated from androidx.core.graphics.drawable to add background color support + * + * Return a [Bitmap] representation of this [Drawable]. + * + * If this instance is a [BitmapDrawable] and the [width], [height], and [config] match, the + * underlying [Bitmap] instance will be returned directly. If any of those three properties differ + * then a new [Bitmap] is created. For all other [Drawable] types, a new [Bitmap] is created. + * + * @param width Width of the desired bitmap. Defaults to [Drawable.getIntrinsicWidth]. + * @param height Height of the desired bitmap. Defaults to [Drawable.getIntrinsicHeight]. + * @param config Bitmap config of the desired bitmap. Null attempts to use the native config, if + * any. Defaults to [Config.ARGB_8888] otherwise. + * @throws IllegalArgumentException if the underlying drawable is a [BitmapDrawable] where + * [BitmapDrawable.getBitmap] returns `null` or the drawable cannot otherwise be represented as a + * bitmap + * @see toBitmapOrNull + */ +fun Drawable.toBitmap( + @Px width: Int = intrinsicWidth, + @Px height: Int = intrinsicHeight, + @ColorInt aBackground: Int = Color.TRANSPARENT, + config: Config? = null +): Bitmap { + if (this is BitmapDrawable) { + if (bitmap == null) { + // This is slightly better than returning an empty, zero-size bitmap. + throw IllegalArgumentException("bitmap is null") + } + if (config == null || bitmap.config == config) { + // Fast-path to return original. Bitmap.createScaledBitmap will do this check, but it + // involves allocation and two jumps into native code so we perform the check ourselves. + if (width == bitmap.width && height == bitmap.height) { + return bitmap + } + return Bitmap.createScaledBitmap(bitmap, width, height, true) + } + } + + val (oldLeft, oldTop, oldRight, oldBottom) = bounds + + val bitmap = Bitmap.createBitmap(width, height, config ?: Config.ARGB_8888) + bitmap.eraseColor(aBackground) + setBounds(0, 0, width, height) + draw(Canvas(bitmap)) + + setBounds(oldLeft, oldTop, oldRight, oldBottom) + return bitmap +} diff --git a/app/src/main/java/com/lin/magic/extensions/FloatExtensions.kt b/app/src/main/java/com/lin/magic/extensions/FloatExtensions.kt new file mode 100644 index 00000000..296da950 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/FloatExtensions.kt @@ -0,0 +1,12 @@ +package com.lin.magic.extensions +import android.content.res.Resources + +/** + * Convert float from dp to px + */ +val Float.px: Float get() = (this * Resources.getSystem().displayMetrics.density) + +/** + * Covert float from px to dp + */ +val Float.dp: Float get() = (this / Resources.getSystem().displayMetrics.density) \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/extensions/IntExtensions.kt b/app/src/main/java/com/lin/magic/extensions/IntExtensions.kt new file mode 100644 index 00000000..f60c4789 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/IntExtensions.kt @@ -0,0 +1,13 @@ +package com.lin.magic.extensions + +import android.content.res.Resources + +/** + * Convert integer from dp to px + */ +val Int.px: Int get() = (this * Resources.getSystem().displayMetrics.density).toInt() + +/** + * Covert integer from px to dp + */ +val Int.dp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/extensions/JSONArrayExtensions.kt b/app/src/main/java/com/lin/magic/extensions/JSONArrayExtensions.kt new file mode 100644 index 00000000..8a24f11c --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/JSONArrayExtensions.kt @@ -0,0 +1,8 @@ +package com.lin.magic.extensions + +import org.json.JSONArray + +/** + * Map each item in a [JSONArray] to a list of a new type. + */ +inline fun JSONArray.map(map: (Any) -> T): List = (0 until length()).map { map(this[it]) } diff --git a/app/src/main/java/com/lin/magic/extensions/ObservableExtensions.kt b/app/src/main/java/com/lin/magic/extensions/ObservableExtensions.kt new file mode 100644 index 00000000..832d7a34 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/ObservableExtensions.kt @@ -0,0 +1,25 @@ +package com.lin.magic.extensions + +import io.reactivex.Observable +import io.reactivex.Single +import java.io.IOException + +/** + * Filters the [Observable] to only instances of [T]. + */ +inline fun Observable.filterInstance(): Observable { + return this.filter { it is T }.map { it as T } +} + +/** + * On an [IOException], resume with the value provided by the [mapper]. + */ +inline fun Single.onIOExceptionResumeNext( + crossinline mapper: (IOException) -> T +): Single = this.onErrorResumeNext { + if (it is IOException) { + Single.just(mapper(it)) + } else { + Single.error(it) + } +} diff --git a/app/src/main/java/com/lin/magic/extensions/PermissionRequestExtensions.kt b/app/src/main/java/com/lin/magic/extensions/PermissionRequestExtensions.kt new file mode 100644 index 00000000..56306da3 --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/PermissionRequestExtensions.kt @@ -0,0 +1,28 @@ +package com.lin.magic.extensions + +import android.Manifest +import android.annotation.TargetApi +import android.os.Build +import android.webkit.PermissionRequest + +/** + * Returns the permissions retrieved from [Manifest.permission] which are required by the requested + * resources. If none of the resources require a permission, the list will be empty. + */ +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +fun PermissionRequest.requiredPermissions(): Set { + return resources.flatMap { + when (it) { + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> listOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MODIFY_AUDIO_SETTINGS + ) + PermissionRequest.RESOURCE_MIDI_SYSEX -> emptyList() + PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID -> emptyList() + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> listOf( + Manifest.permission.CAMERA + ) + else -> emptyList() + } + }.toHashSet() +} diff --git a/app/src/main/java/com/lin/magic/extensions/PreferenceExtensions.kt b/app/src/main/java/com/lin/magic/extensions/PreferenceExtensions.kt new file mode 100644 index 00000000..095c153f --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/PreferenceExtensions.kt @@ -0,0 +1,30 @@ +package com.lin.magic.extensions + +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceGroup + + +/** + * Find the first preference whose fragment property matches the template argument. + * That allows us to fetch preferences from a preference screen without using ids. + */ +inline fun PreferenceGroup.findPreference(): Preference? { + return findPreference(T::class.java) +} + + +/** + * Find the first preference whose fragment property matches the template argument. + * That allows us to fetch preferences from a preference screen without using ids. + */ +fun PreferenceGroup.findPreference(aClass: Class<*>): Preference? { + val preferenceCount: Int = preferenceCount + for (i in 0 until preferenceCount) { + val preference: Preference = getPreference(i) + if (preference.fragment == aClass.name) { + return preference + } + } + return null +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/extensions/PreferenceFragmentCompat.kt b/app/src/main/java/com/lin/magic/extensions/PreferenceFragmentCompat.kt new file mode 100644 index 00000000..0876b04a --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/PreferenceFragmentCompat.kt @@ -0,0 +1,13 @@ +package com.lin.magic.extensions + +import androidx.annotation.StringRes +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat + + +/** + * Find a preference from a key specified as a string resource. + */ +fun PreferenceFragmentCompat.find(@StringRes aId: Int): T? { + return findPreference(getString(aId)) +} \ No newline at end of file diff --git a/app/src/main/java/com/lin/magic/extensions/SnackbarExtensions.kt b/app/src/main/java/com/lin/magic/extensions/SnackbarExtensions.kt new file mode 100644 index 00000000..7862d79a --- /dev/null +++ b/app/src/main/java/com/lin/magic/extensions/SnackbarExtensions.kt @@ -0,0 +1,77 @@ +package com.lin.magic.extensions + +import com.lin.magic.R +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.LayoutRes +import androidx.annotation.StringRes +import androidx.appcompat.content.res.AppCompatResources +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar + +/** + * Adds an extra action button to this snackbar. + * [aLayoutId] must be a layout with a Button as root element. + * [aLabel] defines new button label string. + * [aListener] handles our new button click event. + */ +fun Snackbar.addAction(@LayoutRes aLayoutId: Int, @StringRes aLabel: Int, aListener: View.OnClickListener?) : Snackbar { + addAction(aLayoutId,context.getString(aLabel),aListener) + return this; +} + +/** + * Adds an extra action button to this snackbar. + * [aLayoutId] must be a layout with a Button as root element. + * [aLabel] defines new button label string. + * [aListener] handles our new button click event. + */ +fun Snackbar.addAction(@LayoutRes aLayoutId: Int, aLabel: String, aListener: View.OnClickListener?) : Snackbar { + // Add our button + val button = LayoutInflater.from(view.context).inflate(aLayoutId, null) as Button + // Using our special knowledge of the snackbar action button id we can hook our extra button next to it + view.findViewById