diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 32226e9cfe..961b05c88e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,8 @@ android:required="false" /> - @@ -45,11 +46,11 @@ + + = listOf( GERMANY, AUSTRIA, SWEDEN, diff --git a/app/src/main/java/org/torproject/android/service/OrbotConstants.kt b/app/src/main/java/org/torproject/android/service/OrbotConstants.kt index 22512a9d7e..d40c83c545 100644 --- a/app/src/main/java/org/torproject/android/service/OrbotConstants.kt +++ b/app/src/main/java/org/torproject/android/service/OrbotConstants.kt @@ -145,9 +145,6 @@ object OrbotConstants { "app.accrescent.client" ) - const val ONION_EMOJI: String = "\uD83E\uDDC5" - - // Constants for getting bridges in semi-manual ways. val GET_BRIDES_BRIDGES_URI = "https://bridges.torproject.org/".toUri() diff --git a/app/src/main/java/org/torproject/android/service/OrbotService.java b/app/src/main/java/org/torproject/android/service/OrbotService.java index 08f1b8ceb1..50063703cd 100644 --- a/app/src/main/java/org/torproject/android/service/OrbotService.java +++ b/app/src/main/java/org/torproject/android/service/OrbotService.java @@ -758,10 +758,6 @@ public void onReceive(Context context, Intent intent) { // hack for https://github.com/guardianproject/tor-android/issues/73 remove when fixed var newStatus = intent.getStringExtra(EXTRA_STATUS); - if (STATUS_ON.equals(newStatus) && Prefs.getTransport() == Transport.NONE && !Prefs.getHasDirectConnected()) { - Prefs.setHasDirectConnected(true); - } - if (STATUS_OFF.equals(mCurrentStatus) && STATUS_STOPPING.equals(newStatus)) break; mCurrentStatus = newStatus; diff --git a/app/src/main/java/org/torproject/android/service/circumvention/BuiltInBridges.kt b/app/src/main/java/org/torproject/android/service/circumvention/BuiltInBridges.kt index c206465354..7cbe33978c 100644 --- a/app/src/main/java/org/torproject/android/service/circumvention/BuiltInBridges.kt +++ b/app/src/main/java/org/torproject/android/service/circumvention/BuiltInBridges.kt @@ -135,7 +135,7 @@ data class BuiltInBridges( */ fun getUdpDnstt(context: Context, countryCode: String?): List? { if (countryCode.isNullOrEmpty()) return null - if (countryCode != "global" && !dnsCountries.contains(countryCode.lowercase())) return null + if (countryCode != "global" && !Regionalization.getCountriesWithDnsttSupport().contains(countryCode)) return null val dnsInfo: DnsInfo diff --git a/app/src/main/java/org/torproject/android/ui/OrbotBottomSheetDialogFragment.kt b/app/src/main/java/org/torproject/android/ui/OrbotBottomSheetDialogFragment.kt index 263dd453aa..526c900f85 100644 --- a/app/src/main/java/org/torproject/android/ui/OrbotBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/torproject/android/ui/OrbotBottomSheetDialogFragment.kt @@ -6,6 +6,7 @@ import android.graphics.Color import android.os.Bundle import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.widget.EditText import android.widget.FrameLayout @@ -21,7 +22,9 @@ import org.torproject.android.R Class to set up default bottom sheet behavior for Config Connection, MOAT and any other bottom sheets to come */ -open class OrbotBottomSheetDialogFragment : BottomSheetDialogFragment() { +open class OrbotBottomSheetDialogFragment( + val minMode: Boolean = false +) : BottomSheetDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = BottomSheetDialog(requireActivity(), theme) dialog.setOnShowListener { @@ -30,8 +33,8 @@ open class OrbotBottomSheetDialogFragment : BottomSheetDialogFragment() { bottomSheetView?.let { it.setBackgroundResource(R.drawable.bottom_sheet_rounded) it.setBackgroundColor(Color.TRANSPARENT) - setHeightResponsive(it) val behavior = BottomSheetBehavior.from(it) + setHeightResponsive(it, behavior) behavior.state = BottomSheetBehavior.STATE_EXPANDED } } @@ -39,16 +42,24 @@ open class OrbotBottomSheetDialogFragment : BottomSheetDialogFragment() { return dialog } - private fun setHeightResponsive(bottomSheet: View) { + private fun setHeightResponsive(bottomSheet: View, behavior: BottomSheetBehavior<*>) { val windowMetrics = WindowMetricsCalculator .getOrCreate() .computeCurrentWindowMetrics(requireActivity()) val windowHeight = windowMetrics.bounds.height() val height = (windowHeight * getHeightRatio()).toInt() - val layoutParams = bottomSheet.layoutParams - layoutParams.height = height + + if (minMode) { + behavior.maxHeight = height + + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + else { + layoutParams.height = height + } + bottomSheet.layoutParams = layoutParams } diff --git a/app/src/main/java/org/torproject/android/ui/connect/ConfigConnectionBottomSheet.kt b/app/src/main/java/org/torproject/android/ui/connect/ConfigConnectionBottomSheet.kt index dcb94461d3..1fa8c68299 100644 --- a/app/src/main/java/org/torproject/android/ui/connect/ConfigConnectionBottomSheet.kt +++ b/app/src/main/java/org/torproject/android/ui/connect/ConfigConnectionBottomSheet.kt @@ -26,7 +26,6 @@ import org.torproject.android.Regionalization import org.torproject.android.databinding.ConfigConnectionBottomSheetBinding import org.torproject.android.service.OrbotConstants import org.torproject.android.service.circumvention.AutoConf -import org.torproject.android.service.circumvention.BuiltInBridges import org.torproject.android.service.circumvention.Transport import org.torproject.android.util.Prefs import org.torproject.android.ui.OrbotBottomSheetDialogFragment @@ -90,7 +89,9 @@ class ConfigConnectionBottomSheet : binding.acCountry.onItemClickListener = this binding.dnsttContainer.visibility = - if (BuiltInBridges.dnsCountries.contains(selectedCountryCode?.lowercase())) View.VISIBLE else View.GONE + if (Regionalization.getCountriesWithDnsttSupport() + .contains(selectedCountryCode) + ) View.VISIBLE else View.GONE radios = arrayListOf( binding.rbDirect, @@ -428,7 +429,7 @@ class ConfigConnectionBottomSheet : } private fun updateDnsttVisibility() { - if (BuiltInBridges.dnsCountries.contains(selectedCountryCode?.lowercase())) { + if (Regionalization.getCountriesWithDnsttSupport().contains(selectedCountryCode)) { binding.dnsttContainer.visibility = View.VISIBLE } else { binding.dnsttContainer.visibility = View.GONE diff --git a/app/src/main/java/org/torproject/android/ui/connect/ConnectUiState.kt b/app/src/main/java/org/torproject/android/ui/connect/ConnectUiState.kt index 27c0ec7274..8567504392 100644 --- a/app/src/main/java/org/torproject/android/ui/connect/ConnectUiState.kt +++ b/app/src/main/java/org/torproject/android/ui/connect/ConnectUiState.kt @@ -1,6 +1,12 @@ package org.torproject.android.ui.connect sealed class ConnectUiState { + /** + * NoInternet can mean two things, the device doesn't have *any* reliable WiFi/Cellular/USB signal + * TODO If the device has a VALID WIFI CONNECTION, but the device is using another VPN that is + * blocking Orbot from connecting to the web, NoInternet DOES NOT REGISTER, even though Orbot + * is effectively offline... + */ object NoInternet : ConnectUiState() object Off : ConnectUiState() data class Starting(val bootstrapPercent: Int?) : ConnectUiState() diff --git a/app/src/main/java/org/torproject/android/ui/kindness/KindnessConfigBottomSheet.kt b/app/src/main/java/org/torproject/android/ui/kindness/KindnessConfigBottomSheet.kt index 44beadad06..6a378b2666 100644 --- a/app/src/main/java/org/torproject/android/ui/kindness/KindnessConfigBottomSheet.kt +++ b/app/src/main/java/org/torproject/android/ui/kindness/KindnessConfigBottomSheet.kt @@ -4,45 +4,45 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button -import androidx.appcompat.widget.SwitchCompat -import androidx.fragment.app.FragmentActivity -import org.torproject.android.R -import org.torproject.android.util.Prefs +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import org.torproject.android.databinding.KindnessConfigBottomSheetBinding import org.torproject.android.ui.OrbotBottomSheetDialogFragment +import org.torproject.android.util.Prefs -class KindnessConfigBottomSheet : OrbotBottomSheetDialogFragment() { +class KindnessConfigBottomSheet : OrbotBottomSheetDialogFragment(true) { - private lateinit var btnAction: Button + private lateinit var mBinding: KindnessConfigBottomSheetBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val v = inflater.inflate(R.layout.kindess_config_bottom_sheet, container, false) - v.findViewById(R.id.tvCancel).setOnClickListener { dismiss() } - btnAction = v.findViewById(R.id.btnAction) + ): View { + mBinding = KindnessConfigBottomSheetBinding.inflate(inflater, container, false) + + mBinding.tvCancel.setOnClickListener { dismiss() } - val configWifi = v.findViewById(R.id.swKindnessConfigWifi) - val configCharging = v.findViewById(R.id.swKindnessConfigCharging) + mBinding.btnAction.setOnClickListener { + Prefs.setBeSnowflakeProxyLimitWifi(mBinding.swKindnessConfigWifi.isChecked) + Prefs.setBeSnowflakeProxyLimitCharging(mBinding.swKindnessConfigCharging.isChecked) - btnAction.setOnClickListener { - Prefs.setBeSnowflakeProxyLimitWifi(configWifi.isChecked) - Prefs.setBeSnowflakeProxyLimitCharging(configCharging.isChecked) + setFragmentResult(KEY_CONFIG_CHANGED, Bundle()) dismiss() } - configWifi.isChecked = Prefs.limitSnowflakeProxyingWifi() - configCharging.isChecked = Prefs.limitSnowflakeProxyingCharging() - return v + mBinding.swKindnessConfigWifi.isChecked = Prefs.limitSnowflakeProxyingWifi() + mBinding.swKindnessConfigCharging.isChecked = Prefs.limitSnowflakeProxyingCharging() + + return mBinding.root } companion object { - fun openKindnessSettings(fragmentActivity: FragmentActivity) { + const val KEY_CONFIG_CHANGED = "kindness_config_changed" + + fun show(fragmentManager: FragmentManager) { KindnessConfigBottomSheet().show( - fragmentActivity.supportFragmentManager, + fragmentManager, "KindnessConfig" ) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/torproject/android/ui/kindness/KindnessFragment.kt b/app/src/main/java/org/torproject/android/ui/kindness/KindnessFragment.kt index c1f053c0c5..f8615a94ca 100644 --- a/app/src/main/java/org/torproject/android/ui/kindness/KindnessFragment.kt +++ b/app/src/main/java/org/torproject/android/ui/kindness/KindnessFragment.kt @@ -1,118 +1,227 @@ package org.torproject.android.ui.kindness +import IPtProxy.IPtProxy import android.app.AlertDialog +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.content.SharedPreferences +import android.content.res.ColorStateList import android.os.Bundle +import android.os.IBinder import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import androidx.appcompat.widget.SwitchCompat +import androidx.core.net.toUri +import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager import org.torproject.android.R -import org.torproject.android.service.circumvention.BuiltInBridges -import org.torproject.android.service.circumvention.Transport +import org.torproject.android.Regionalization +import org.torproject.android.databinding.FragmentKindnessBinding import org.torproject.android.util.Prefs -import java.util.Locale -import kotlin.collections.contains class KindnessFragment : Fragment() { - private lateinit var tvAllTimeTotal: TextView - private lateinit var tvWeeklyTotal: TextView - private lateinit var swVolunteerMode: SwitchCompat - private lateinit var btnActionActivate: Button - private lateinit var pnlActivate: View - private lateinit var pnlStatus: View + private lateinit var mBinding: FragmentKindnessBinding + private var mService: SnowflakeProxyService? = null + private var mBound = false + + private val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as SnowflakeProxyService.LocalBinder + mService = binder.getService() + mBound = true + } + + override fun onServiceDisconnected(arg0: ComponentName) { + mBound = false + mService = null + } + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_kindness, container, false) - tvAllTimeTotal = view.findViewById(R.id.tvAlltimeTotal) - tvWeeklyTotal = view.findViewById(R.id.tvWeeklyTotal) - swVolunteerMode = view.findViewById(R.id.swVolunteerMode) - btnActionActivate = view.findViewById(R.id.btnActionActivate) - pnlActivate = view.findViewById(R.id.panel_kindness_activate) - pnlStatus = view.findViewById(R.id.panel_kindness_status) - getErrorStringIfAny()?.let { - Prefs.setBeSnowflakeProxy(false) - } - swVolunteerMode.isChecked = Prefs.beSnowflakeProxy() - swVolunteerMode.setOnCheckedChangeListener { _, isChecked -> + ): View { + mBinding = FragmentKindnessBinding.inflate(inflater) + + mBinding.swVolunteerMode.isChecked = Prefs.beSnowflakeProxy() + mBinding.swVolunteerMode.setOnCheckedChangeListener { _, isChecked -> Prefs.setBeSnowflakeProxy(isChecked) - showPanelStatus(isChecked) activity?.let { if (isChecked) { SnowflakeProxyService.startSnowflakeProxyForegroundService(it) } else { SnowflakeProxyService.stopSnowflakeProxyForegroundService(it) + + updateNatTypeUi(IPtProxy.NATUnknown) } } } - view.findViewById(R.id.ivGear).setOnClickListener { - KindnessConfigBottomSheet.openKindnessSettings(requireActivity()) + mBinding.rowUsageLimits.setOnClickListener { + KindnessConfigBottomSheet.show(parentFragmentManager) } - view.findViewById(R.id.swVolunteerAdjust) - .setOnClickListener { KindnessConfigBottomSheet.openKindnessSettings(requireActivity()) } + updateNatTypeUi(IPtProxy.NATUnknown) - btnActionActivate.setOnClickListener { - getErrorStringIfAny()?.let { - showDisabledDialog(it) - return@setOnClickListener + mBinding.rowProxyQuality.setOnClickListener { + if (Prefs.lastSnowflakeNatType == IPtProxy.NATRestricted) { + showQualityHint() } - swVolunteerMode.isChecked = true } - showPanelStatus(Prefs.beSnowflakeProxy()) - return view - } + mBinding.btnActionActivate.setOnClickListener { + TestingDialogFragment.show(parentFragmentManager) + } + + if (Regionalization.isKindnessModeDisabledForCountry()) { + mBinding.btnActionActivate.isEnabled = false + + // set text explaining that kindness mode isn't available from the user's country + mBinding.tvActivateInstructions.text = + getString( + R.string.kindness_mode_unsupported_country, + Regionalization.getLocalizedNameForCountryCode() + ) - private fun getErrorStringIfAny(): Int? { - val country = Prefs.bridgeCountry?.lowercase(Locale.getDefault()) - if (BuiltInBridges.dnsCountries.contains(country)) - return R.string.kindness_mode_cant_run_in_your_country - if (Prefs.useVpn() && Prefs.transport != Transport.NONE) - R.string.kindness_mode_cant_run_with_bridge - if (!Prefs.hasDirectConnected) { - return R.string.kindness_never_had_a_direct_connection + // set the activate button to be gray, making it not the primary button + ViewCompat.setBackgroundTintList( + mBinding.btnActionActivate, + ColorStateList.valueOf(resources.getColor(R.color.orbot_btn_disable_grey, null)) + ) + ViewCompat.setBackgroundTintList( + mBinding.btnActionLearnMore, + ColorStateList.valueOf(resources.getColor(R.color.orbot_btn_enabled_purple, null)) + ) } - return null - } - private fun showDisabledDialog(msg: Int) { - AlertDialog.Builder(requireContext()) - .setTitle(R.string.kindness_mode_cant_start) - .setMessage(msg) - .setPositiveButton(android.R.string.ok, null) - .show() + mBinding.btnActionLearnMore.setOnClickListener { + val i = Intent(Intent.ACTION_VIEW, "https://orbot.app/kindness".toUri()) + val pm = context?.packageManager + + if (pm != null && i.resolveActivity(pm) != null) { + startActivity(i) + } + } + + showPanelStatus(!Prefs.snowflakeNeedsQualityCheck) + + parentFragmentManager.setFragmentResultListener( + KindnessConfigBottomSheet.KEY_CONFIG_CHANGED, + viewLifecycleOwner + ) { _, _ -> + updateUsageLimitsUi() + } + + parentFragmentManager.setFragmentResultListener( + TestingDialogFragment.KEY_RESULT, + viewLifecycleOwner + ) { _, bundle -> + + if (bundle.getBoolean(TestingDialogFragment.KEY_RESULT)) { + if (!Prefs.snowflakeNeedsQualityCheck) { + mBinding.swVolunteerMode.isChecked = true + showPanelStatus(true) + } + } + } + + return mBinding.root } override fun onResume() { super.onResume() - // updates these values when user returns to screen after running snowflake proxy for some time - tvAllTimeTotal.text = "${Prefs.snowflakesServed}" - tvWeeklyTotal.text = "${Prefs.snowflakesServedWeekly}" + // Updates these values when user returns to screen after running snowflake proxy for some time. + updateUsageLimitsUi() + updateNatTypeUi(Prefs.lastSnowflakeNatType) + mBinding.tvAlltimeTotal.text = "${Prefs.snowflakesServed}" + mBinding.tvWeeklyTotal.text = "${Prefs.snowflakesServedWeekly}" + } + + private val natTypeObserver = + SharedPreferences.OnSharedPreferenceChangeListener { sharedPrefs, key -> + if (key != Prefs.PREF_LAST_SNOWFLAKE_NAT_TYPE) return@OnSharedPreferenceChangeListener + updateNatTypeUi(Prefs.lastSnowflakeNatType) + } + + override fun onStart() { + super.onStart() + PreferenceManager + .getDefaultSharedPreferences(requireContext()) + .registerOnSharedPreferenceChangeListener(natTypeObserver) + } + + override fun onStop() { + super.onStop() + PreferenceManager + .getDefaultSharedPreferences(requireContext()) + .unregisterOnSharedPreferenceChangeListener(natTypeObserver) + + if (mBound) { + context?.unbindService(connection) + mBound = false + } + } + + private fun updateNatTypeUi(natType: String) { + mBinding.tvProxyQualityStatus.text = when (natType) { + IPtProxy.NATUnknown -> getString(R.string.kindness_proxy_quality_unknown) + IPtProxy.NATRestricted -> getString(R.string.kindness_proxy_quality_restricted) + IPtProxy.NATUnrestricted -> getString(R.string.kindness_proxy_quality_unrestricted) + else -> natType + } + + if (natType == IPtProxy.NATRestricted) { + mBinding.redDot.visibility = View.VISIBLE + mBinding.chevron2.visibility = View.VISIBLE + } else { + mBinding.redDot.visibility = View.GONE + mBinding.chevron2.visibility = View.GONE + } + } + + private fun updateUsageLimitsUi() { + mBinding.tvUsageLimitsStatus.text = + getString( + if (Prefs.limitSnowflakeProxyingWifi() || Prefs.limitSnowflakeProxyingCharging()) + R.string.kindness_usage_limits_status_on + else R.string.kindness_usage_limits_status_off + ) + } + + private fun showQualityHint() { + val context = context ?: return + + AlertDialog.Builder(context) + .setTitle(R.string.kindness_quality_upgrade_title) + .setMessage( + String.format( + "%s\n\n%s", + getString(R.string.kindness_quality_upgrade_line1), + getString(R.string.kindness_quality_upgrade_line2) + ) + ) + .setPositiveButton(android.R.string.ok, null) + .show() } private fun showPanelStatus(isActivated: Boolean) { val duration = 250L if (isActivated) { - pnlActivate.animate().alpha(0f).setDuration(0).withEndAction { - pnlActivate.visibility = View.GONE + mBinding.panelKindnessActivate.animate().alpha(0f).setDuration(0).withEndAction { + mBinding.panelKindnessActivate.visibility = View.GONE } - pnlStatus.visibility = View.VISIBLE - pnlStatus.animate().alpha(1f).duration = duration + mBinding.panelKindnessStatus.visibility = View.VISIBLE + mBinding.panelKindnessStatus.animate().alpha(1f).duration = duration } else { - pnlActivate.visibility = View.VISIBLE - pnlActivate.animate().alpha(1f).duration = duration + mBinding.panelKindnessActivate.visibility = View.VISIBLE + mBinding.panelKindnessActivate.animate().alpha(1f).duration = duration - pnlStatus.animate().alpha(0f).setDuration(0).withEndAction { - pnlStatus.visibility = View.GONE + mBinding.panelKindnessStatus.animate().alpha(0f).setDuration(0).withEndAction { + mBinding.panelKindnessStatus.visibility = View.GONE } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyService.kt b/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyService.kt index ee3824e4c0..bbb95effad 100644 --- a/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyService.kt +++ b/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyService.kt @@ -10,6 +10,7 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.os.Binder import android.os.Build import android.os.IBinder import android.util.Log @@ -21,15 +22,21 @@ import org.torproject.android.util.Prefs class SnowflakeProxyService : Service() { + inner class LocalBinder : Binder() { + fun getService(): SnowflakeProxyService = this@SnowflakeProxyService + } + + private val binder = LocalBinder() + private lateinit var snowflakeProxyWrapper: SnowflakeProxyWrapper private lateinit var powerConnectionReceiver: PowerConnectionReceiver private lateinit var notificationChannelId: String private lateinit var networkCallbacks: ConnectivityManager.NetworkCallback - override fun onBind(intent: Intent?): IBinder? { + override fun onBind(intent: Intent?): IBinder { Log.d(TAG, "onBind: $intent") - return null + return binder } override fun onCreate() { @@ -102,6 +109,7 @@ class SnowflakeProxyService : Service() { val hasWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true val hasVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + if (Prefs.limitSnowflakeProxyingWifi() && !hasWifi) { refreshNotification(getString(R.string.kindness_mode_disabled_wifi)) stopSnowflakeProxy("required wifi condition not met") @@ -111,9 +119,9 @@ class SnowflakeProxyService : Service() { stopSnowflakeProxy("has network, but non Orbot VPN is running") return } + stopSnowflakeProxy("stopping on new network event to refresh NAT type") startSnowflakeProxy("got network (wifi=${hasWifi}, limit wifi=${Prefs.limitSnowflakeProxyingWifi()}") - } - else { + } else { refreshNotification(getString(R.string.kindness_mode_disabled_internet)) } } @@ -144,6 +152,7 @@ class SnowflakeProxyService : Service() { private fun stopSnowflakeProxy(logMessage: String? = null) { Log.d(TAG, "Stopping snowflake proxy - reason: $logMessage") + Prefs.lastSnowflakeNatType = IPtProxy.IPtProxy.NATUnknown snowflakeProxyWrapper.stopProxy() } @@ -170,24 +179,20 @@ class SnowflakeProxyService : Service() { private const val CHANNEL_ID = "snowflake" private const val ACTION_STOP_SNOWFLAKE_SERVICE = "ACTION_STOP_SNOWFLAKE_SERVICE" - private fun getIntent(context: Context) = Intent(context, SnowflakeProxyService::class.java) + fun getIntent(context: Context) = Intent(context, SnowflakeProxyService::class.java) // start this service, but not necessarily snowflake proxy from the app UI - fun startSnowflakeProxyForegroundService(context: Context) { + fun startSnowflakeProxyForegroundService(context: Context) = ContextCompat.startForegroundService( context, getIntent(context) ) - } // stop this service, and snowflake proxy if its running, from the app UI - - fun stopSnowflakeProxyForegroundService(context: Context) { + fun stopSnowflakeProxyForegroundService(context: Context) = ContextCompat.startForegroundService( context, getIntent(context).setAction(ACTION_STOP_SNOWFLAKE_SERVICE) ) - } - } } diff --git a/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyWrapper.kt b/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyWrapper.kt index 26323bbbb5..e98964e5c2 100644 --- a/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyWrapper.kt +++ b/app/src/main/java/org/torproject/android/ui/kindness/SnowflakeProxyWrapper.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.torproject.android.R import org.torproject.android.service.OrbotConstants -import org.torproject.android.service.OrbotConstants.ONION_EMOJI import org.torproject.android.service.circumvention.BuiltInBridges import org.torproject.android.util.Prefs import org.torproject.android.util.showToast @@ -49,9 +48,9 @@ class SnowflakeProxyWrapper(private val service: SnowflakeProxyService) { releaseMappedPorts() } - val stunServers = BuiltInBridges.getInstance(service)?.snowflake?.firstOrNull()?.ice - ?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() - ?: emptyArray() + val stunServers = + BuiltInBridges.getInstance(service)?.snowflake?.firstOrNull()?.ice?.split(",".toRegex()) + ?.dropLastWhile { it.isEmpty() }?.toTypedArray() ?: emptyArray() val stunUrl = stunServers[SecureRandom().nextInt(stunServers.size)] proxy = SnowflakeProxy() @@ -65,18 +64,9 @@ class SnowflakeProxyWrapper(private val service: SnowflakeProxyService) { this?.relayUrl = fronts["snowflake-relay-url"] this?.natProbeUrl = fronts["snowflake-nat-probe"] this?.clientEvents = object : SnowflakeClientEvents { - override fun connected() { - onConnected() - } - - override fun connectionFailed() { - // Ignored. - } - - override fun disconnected(country: String?) { - // Ignored. - } - + override fun connected() = onConnected() + override fun connectionFailed() {} + override fun disconnected(country: String?) {} override fun stats( connectionCount: Long, failedConnectionCount: Long, @@ -86,11 +76,10 @@ class SnowflakeProxyWrapper(private val service: SnowflakeProxyService) { outboundUnit: String?, summaryInterval: Long ) { - // Ignored. } - override fun natTypeUpdated(natType: String?) { - // TODO feature added in IPtProxy 5.4.1 + override fun natTypeUpdated(natType: String) { + Prefs.lastSnowflakeNatType = natType } } @@ -107,6 +96,7 @@ class SnowflakeProxyWrapper(private val service: SnowflakeProxyService) { @Synchronized fun stopProxy() { if (proxy == null) return + proxy?.stop() proxy = null @@ -153,4 +143,8 @@ class SnowflakeProxyWrapper(private val service: SnowflakeProxyService) { } return map } + + companion object { + private const val ONION_EMOJI: String = "\uD83E\uDDC5" + } } diff --git a/app/src/main/java/org/torproject/android/ui/kindness/TestTorForSnowflakeProxyService.kt b/app/src/main/java/org/torproject/android/ui/kindness/TestTorForSnowflakeProxyService.kt new file mode 100644 index 0000000000..c97012ad21 --- /dev/null +++ b/app/src/main/java/org/torproject/android/ui/kindness/TestTorForSnowflakeProxyService.kt @@ -0,0 +1,80 @@ +package org.torproject.android.ui.kindness + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.core.content.ContextCompat +import org.torproject.android.util.DiskUtils +import org.torproject.jni.TorService + +class TestTorForSnowflakeProxyService : TorService() { + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate()") + } + + + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "onDestroy()") + } + + companion object { + const val TAG = "SnowflakeTestTorService" + + fun launchTorTestingService( + orbotActivity: Activity, + torStatusReceiver: BroadcastReceiver, + ): ServiceConnection { + Log.wtf(TAG, "Preparing to launch tor testing service...") + + // 1. Write a barebones torrc to disk + writeMinimalTorrcToDisk(orbotActivity) + + // 2. Subscribe to status events + ContextCompat.registerReceiver( + orbotActivity, + torStatusReceiver, + IntentFilter(ACTION_STATUS), + RECEIVER_NOT_EXPORTED + ) + + val serviceConnection = getServiceConnection() + + // 3. Bind the Service, starting tor... + orbotActivity.bindService( + Intent(orbotActivity, TestTorForSnowflakeProxyService::class.java), + serviceConnection, + BIND_AUTO_CREATE + ) + + return serviceConnection + } + + + private fun writeMinimalTorrcToDisk(orbotActivity: Activity) { + // write the bare minimum torrc needed to directly connect to the tor network + val minimalTorrc = listOf("RunAsDaemon 1", "AvoidDiskWrites 1").joinToString("\n") + val torrcFile = getTorrc(orbotActivity) + DiskUtils.flushTextToFile(torrcFile, minimalTorrc, append = false) + } + + // Activities are connected to Services via these objects, we bind and unbind to this + // Service via a reference to the object returned by this method... + private fun getServiceConnection(): ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binding: IBinder?) { + Log.d(TAG, "ServiceConnection: onServiceConnected") + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "ServiceConnection: onServiceDisconnected") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/torproject/android/ui/kindness/TestingDialogFragment.kt b/app/src/main/java/org/torproject/android/ui/kindness/TestingDialogFragment.kt new file mode 100644 index 0000000000..ab9904964a --- /dev/null +++ b/app/src/main/java/org/torproject/android/ui/kindness/TestingDialogFragment.kt @@ -0,0 +1,305 @@ +package org.torproject.android.ui.kindness + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.torproject.android.R +import org.torproject.android.databinding.FragmentTestingBinding +import org.torproject.android.service.circumvention.Transport +import org.torproject.android.service.vpn.VpnServicePrepareWrapper +import org.torproject.android.ui.connect.ConnectUiState +import org.torproject.android.ui.connect.ConnectViewModel +import org.torproject.android.util.NetworkUtils +import org.torproject.android.util.Prefs +import org.torproject.android.util.sendIntentToService +import org.torproject.jni.TorService +import kotlin.getValue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Kindness Mode Quality Test + * + * - First, immediately fail the test if there's a non orbot VPN running + * - Second, if we've passed a quality test in the past 24 hours, skip retesting + * otherwise, take the test: + * A: if the user is connected to tor with no bridges/proxy, you pass + * B: if the user isn't connected to tor, warn the user about connecting to tor and attempt + * a direct connection. Pass if we succeed. + * C: if the user has a bridge/proxy, turn tor off. perform option B. When the test is + * completed, turn the user's original Tor connection back on. + * + * Set Prefs.snowflakeNeedsQualityCheck to false if test passes, true if otherwise + */ +class TestingDialogFragment : DialogFragment() { + + private lateinit var mBinding: FragmentTestingBinding + val torConnectedViewModel: ConnectViewModel by activityViewModels() + + private var stoppedNormalTorConnection = false + + private var connectionTestServiceConnection: ServiceConnection? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // disable device rotation while this dialog is running + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mBinding = FragmentTestingBinding.inflate(inflater, container, false) + + mBinding.tvTitleApproved.text = getString(R.string.testing_title_approved, "✅") + mBinding.tvTitleDeclined.text = getString(R.string.testing_title_declined, "\uD83D\uDEAB") + mBinding.btnAbortTest.setOnClickListener { dismiss() } + mBinding.btContinue.setOnClickListener { + setFragmentResult(KEY_RESULT, Bundle().apply { + putBoolean(KEY_RESULT, true) + }) + dismiss() + } + + mBinding.btnDeclinedBoxOk.setOnClickListener { + setFragmentResult(KEY_RESULT, Bundle()) + dismiss() + } + + return mBinding.root + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + window.setBackgroundDrawableResource(android.R.color.transparent) + dialog?.window?.setLayout( + (resources.displayMetrics.widthPixels * 0.9).toInt(), + ViewGroup.LayoutParams.WRAP_CONTENT + ) + + // benign tests to immediately see if the user can/can't use kindness mode + // if we don't get a definite answer, prompt the user for consent to determine for sure + doQualityTestRequiringNoUserConsent() + } + + } + + /** + * This part of the connection test doesn't require user consent + * It automatically fails if: + * - Orbot doesn't have a direct Internet connection + * - the user is using a non-Orbot VPN + * + * If we didn't automatically fail, we can automatically pass if: + * - the user currently has a direct connection to the tor network + * - the user has passed the quality test in the past 24 hours + * + * Otherwise, the test is still inconclusive. Obtain the user's consent to complete the text + * and give them the option to stop testing... + */ + private fun doQualityTestRequiringNoUserConsent() { + + // immediately fail if there's another VPN running + if (NetworkUtils.isNonOrbotVpnActive(requireContext())) { + showTestFailedUi( + errorExplanation = getString(R.string.testing_explanation_other_vpn), + bubbleMsg = getString(R.string.testing_explanation_other_vpn_bubble), + bubbleAction = { + VpnServicePrepareWrapper.openVpnSystemSettings(this) + dismiss() + } + ) + return + } + + // immediately fail if there's no internet + val torConnectionState = torConnectedViewModel.uiState.value + if (torConnectionState == ConnectUiState.NoInternet) { + showTestFailedUi(bubbleMsg = getString(R.string.testing_explanation_no_net)) + return + } + + // immediately succeed if we've recently succeeded + if (!Prefs.snowflakeNeedsQualityCheck) { + Log.wtf(TAG, "recently passed quality check, proceeding") + mBinding.btContinue.callOnClick() + return + } + + // immediately succeed if you're already connecting directly to Tor + if (torConnectionState == ConnectUiState.On && Prefs.transport == Transport.NONE && Prefs.outboundProxy.first == null) { + Log.wtf(TAG, "there's an active direct connection to tor, stop testing") + Prefs.snowflakeNeedsQualityCheck = false + mBinding.btContinue.callOnClick() + return + } + + // at this point, we need to obtain user consent to actually do the connection test... + showUserConsentUI() + } + + private fun showUserConsentUI() { + with(mBinding.btnAbortTest) { + visibility = View.VISIBLE + setOnClickListener { dismiss() } + } + with(mBinding.btnStartTestWithConsent) { + visibility = View.VISIBLE + setOnClickListener { + showUserConsentUI() + doQualityTestRequiringConsent() + } + } + + // if there's a tor connection over a bridge, explain we have to shut tor off + if (isOrbotOnOrStarting()) { + mBinding.tvTestingDisconnectVpnDisclaimer.visibility = View.VISIBLE + mBinding.tvDisclaimerConnectionLeak.visibility = View.VISIBLE + } + } + + private fun isOrbotOnOrStarting(): Boolean { + val torConnectionState = torConnectedViewModel.uiState.value + return torConnectionState is ConnectUiState.On || torConnectionState is ConnectUiState.Starting + } + + + /* set UI for when the connecting directly to tor test is underway */ + private fun showOngoingTestWithConsentUi() { + mBinding.progress.visibility = View.VISIBLE + mBinding.tvTestingConsentTorDisclaimer.visibility = View.GONE + mBinding.tvDisclaimerConnectionLeak.visibility = View.GONE + mBinding.tvTestingHeader.text = getString(R.string.testing_explanation_testing) + mBinding.btnAbortTest.visibility = View.GONE + mBinding.btnStartTestWithConsent.visibility = View.GONE + mBinding.tvTitleTesting.text = getString(R.string.testing_title_testing) + mBinding.tvTestingDisconnectVpnDisclaimer.visibility = View.GONE + } + + /** This part of the connection test requires the user's consent, since it involves attempting + * a direct tor connection that censors can trivially detect, and possibly also temporarily + * disabling Orbot VPN if there's an active connection with censorship circumvention tech. + */ + private fun doQualityTestRequiringConsent() { + showOngoingTestWithConsentUi() + lifecycleScope.launch { + if (isOrbotOnOrStarting()) { + Log.wtf(TAG, "OrbotService is running, we need to turn it off") + stoppedNormalTorConnection = true + requireActivity().sendIntentToService(TorService.ACTION_STOP) + delay(250.milliseconds) + } + + if (torConnectedViewModel.uiState.value != ConnectUiState.Off) { + stoppedNormalTorConnection = false + showTestFailedUi() + Log.wtf(TAG, "OrbotService isn't off yet") + } + + Log.wtf(TAG, "current tor state is ${torConnectedViewModel.uiState.value}") + + connectionTestServiceConnection = + TestTorForSnowflakeProxyService.launchTorTestingService( + requireActivity(), + torStatusReceiver + ) + + delay(CONNECTION_TEST_TIMEOUT.seconds) + // if we haven't established a connection, cleanup and show error state + if (connectionTestServiceConnection != null) { + Log.wtf( + TAG, + "Couldn't establish a tor connection after waiting for $CONNECTION_TEST_TIMEOUT seconds" + ) + unbindServiceIfBound() + showTestFailedUi() + } + } + } + + val torStatusReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val status = intent?.getStringExtra(TorService.EXTRA_STATUS) + Log.wtf(TAG, "Got tor status from testing service: $status") + if (status == TorService.STATUS_ON) { + lifecycleScope.launch { + Prefs.snowflakeNeedsQualityCheck = false + unbindServiceIfBound() + showTestPassedUi() + if (stoppedNormalTorConnection) { + delay(250.milliseconds) + Log.wtf(TAG, "relaunching OrbotService...") + requireActivity().sendIntentToService(TorService.ACTION_START) + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + unbindServiceIfBound() + // restore device rotation + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + } + + private fun unbindServiceIfBound() { + if (connectionTestServiceConnection != null) { + Log.wtf(TAG, "unregistering receiver, killing service") + val connection = connectionTestServiceConnection!! + requireActivity().unregisterReceiver(torStatusReceiver) + requireActivity().unbindService(connection) + connectionTestServiceConnection = null + } + } + + fun showTestPassedUi() { + Prefs.snowflakeNeedsQualityCheck = false + mBinding.boxTesting.visibility = View.GONE + mBinding.boxApproved.visibility = View.VISIBLE + } + + fun showTestFailedUi( + errorExplanation: String? = null, + bubbleMsg: String? = null, + bubbleAction: View.OnClickListener = {} + ) { + Prefs.snowflakeNeedsQualityCheck = true + mBinding.boxTesting.visibility = View.GONE + mBinding.boxDeclined.visibility = View.VISIBLE + errorExplanation?.let { + mBinding.tvExplanationDeclined.text = errorExplanation + } + bubbleMsg?.let { + mBinding.tvErrorBubbleMessage.text = bubbleMsg + mBinding.tvErrorBubbleMessage.setOnClickListener(bubbleAction) + } + } + + companion object { + const val KEY_RESULT = "kindness_test_result" + const val TAG = "TestingFragment" + const val CONNECTION_TEST_TIMEOUT = 90 + + fun show(fragmentManager: FragmentManager) { + TestingDialogFragment().show(fragmentManager, TAG) + } + } +} diff --git a/app/src/main/java/org/torproject/android/util/NetworkUtils.kt b/app/src/main/java/org/torproject/android/util/NetworkUtils.kt index 10bf2f7952..cae4dbf480 100644 --- a/app/src/main/java/org/torproject/android/util/NetworkUtils.kt +++ b/app/src/main/java/org/torproject/android/util/NetworkUtils.kt @@ -3,6 +3,8 @@ package org.torproject.android.util import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.net.VpnService +import android.util.Log import java.net.InetSocketAddress import java.net.Socket @@ -21,6 +23,35 @@ object NetworkUtils { } } + /** Used for kindness mode connection test, returns true *if and only if* Orbot is the registered + * VPN app. We can't use Prefs.useVpn() since this only tells us if Orbot is the registered VPN + * app when Tor is on. When it's off, we don't know which, if any VPN, is configured. + * + * - first cheaply check Prefs.useVpn(), this is true when orbot is running + * - if not, check to see if the system sees a VPN connection, return false if not + * - ensure for certain that Orbot isn't the registered VPN, this can be done by seeing if the + * system gives Orbot an Intent to register to be the active VPN app. If it's non-null, we + * know for certain we have a non-Orbot VPN config on the system + */ + fun isNonOrbotVpnActive(context: Context): Boolean { + if (Prefs.useVpn()) { + return false + } + + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + val deviceUsingVpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + Log.wtf("bim", "VPN? $deviceUsingVpn") + + // we either don't have a VPN app running, if it is, check for certain it's not Orbot + if (!deviceUsingVpn) return false + val isOrbotRegisteredAsVpn = VpnService.prepare(context) != null + Log.wtf("bim", "isOrbotRegisteredAsVpn: $isOrbotRegisteredAsVpn") + return isOrbotRegisteredAsVpn + } + fun checkPortOrAuto(portString: String): String { if (!portString.equals("auto", ignoreCase = true)) { var isPortUsed = true diff --git a/app/src/main/java/org/torproject/android/util/Prefs.kt b/app/src/main/java/org/torproject/android/util/Prefs.kt index fd3cb0d940..2f7bea05df 100644 --- a/app/src/main/java/org/torproject/android/util/Prefs.kt +++ b/app/src/main/java/org/torproject/android/util/Prefs.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager +import org.torproject.android.Regionalization import org.torproject.android.service.OrbotConstants import org.torproject.android.service.circumvention.Transport import org.torproject.android.service.tor.ShadowSocks @@ -15,6 +16,7 @@ import java.util.concurrent.TimeUnit object Prefs { private const val PREF_BRIDGES_LIST = "pref_bridges_list" + private const val PREF_BRIDGE_COUNTRY = "pref_bridge_country" private const val PREF_DEFAULT_LOCALE = "pref_default_locale" private const val PREF_DETECT_ROOT = "pref_detect_root" private const val PREF_ENABLE_LOGGING = "pref_enable_logging" @@ -22,12 +24,13 @@ object Prefs { private const val PREF_ALLOW_BACKGROUND_STARTS = "pref_allow_background_starts" private const val PREF_OPEN_PROXY_ON_ALL_INTERFACES = "pref_open_proxy_on_all_interfaces" private const val PREF_USE_VPN = "pref_vpn" - private const val PREF_DIRECT_CONNECT_SUCCESS = "pref_direct_connect" + private const val PREF_LAST_SNOWFLAKE_QUALITY_CHECK = "pref_last_snowflake_quality_check" private const val PREF_EXIT_NODES = "pref_exit_nodes" private const val PREF_BE_A_SNOWFLAKE = "pref_be_a_snowflake" private const val PREF_SHOW_SNOWFLAKE_MSG = "pref_show_snowflake_proxy_msg" private const val PREF_BE_A_SNOWFLAKE_LIMIT_WIFI = "pref_be_a_snowflake_limit_wifi" private const val PREF_BE_A_SNOWFLAKE_LIMIT_CHARGING = "pref_be_a_snowflake_limit_charing" + const val PREF_LAST_SNOWFLAKE_NAT_TYPE = "pref_snowflake_last_nat" private const val PREF_USE_SMART_CONNECT = "pref_use_smart_connect" private const val PREF_SMART_CONNECT_TIMEOUT = "pref_smart_connect_timeout" @@ -101,8 +104,16 @@ object Prefs { } var bridgeCountry: String? - get() = cr?.getPrefString("pref_bridge_country") - set(value) = cr?.putPref("pref_bridge_country", value) ?: Unit + get() = cr?.getPrefString(PREF_BRIDGE_COUNTRY) + set(value) { + cr?.let { + it.putPref(PREF_BRIDGE_COUNTRY, value) + if (Regionalization.isKindnessModeDisabledForCountry()) { + setBeSnowflakeProxy(beSnowflakeProxy = false) + snowflakeNeedsQualityCheck = true + } + } + } @JvmStatic var defaultLocale: String @@ -164,11 +175,19 @@ object Prefs { cr?.putPref(PREF_USE_VPN, value) } - @JvmStatic - var hasDirectConnected: Boolean - get() = cr?.getPrefBoolean(PREF_DIRECT_CONNECT_SUCCESS) ?: false - set(value) = cr?.putPref(PREF_DIRECT_CONNECT_SUCCESS, value) ?: Unit + var snowflakeNeedsQualityCheck: Boolean + get() { + val last = cr?.getPrefLong(PREF_LAST_SNOWFLAKE_QUALITY_CHECK) ?: 0 + // A new quality check should be done every 24 hours. + return last <= System.currentTimeMillis() - 24 * 60 * 60 * 1000 + } + set(value) { + cr?.putPref( + PREF_LAST_SNOWFLAKE_QUALITY_CHECK, + if (value) 0 else System.currentTimeMillis() + ) + } fun startOnBoot(): Boolean { return cr?.getPrefBoolean(PREF_START_ON_BOOT, true) ?: true @@ -179,6 +198,10 @@ object Prefs { get() = cr?.getPrefString(PREF_EXIT_NODES) set(country) = cr?.putPref(PREF_EXIT_NODES, country) ?: Unit + var lastSnowflakeNatType: String + get() = cr?.getPrefString(PREF_LAST_SNOWFLAKE_NAT_TYPE) ?: IPtProxy.IPtProxy.NATUnknown + set(natType) = cr?.putPref(PREF_LAST_SNOWFLAKE_NAT_TYPE, natType) ?: Unit + val snowflakesServed: Int get() = cr?.getPrefInt(PREF_SNOWFLAKES_SERVED_COUNT) ?: 0 @@ -226,8 +249,7 @@ object Prefs { return try { Pair(URI(config), null) - } - catch (_: URISyntaxException) { + } catch (_: URISyntaxException) { Pair(null, config) } } diff --git a/app/src/main/res/drawable/bg_modal_rounded.xml b/app/src/main/res/drawable/bg_modal_rounded.xml new file mode 100644 index 0000000000..2be0a25b6e --- /dev/null +++ b/app/src/main/res/drawable/bg_modal_rounded.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_rounded_orange.xml b/app/src/main/res/drawable/bg_rounded_orange.xml new file mode 100644 index 0000000000..35f0504b4e --- /dev/null +++ b/app/src/main/res/drawable/bg_rounded_orange.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml index 93e83fc3f4..eedd464160 100644 --- a/app/src/main/res/drawable/ic_heart.xml +++ b/app/src/main/res/drawable/ic_heart.xml @@ -1,10 +1,12 @@ + android:viewportWidth="24" + android:viewportHeight="24"> - \ No newline at end of file + android:pathData="M12,9C12,9 12,3 17.25,3C22.5,3 22.5,7.5 22.5,9C22.5,15.75 12,22.5 12,22.5V9Z" + android:fillColor="#7D7C80"/> + + diff --git a/app/src/main/res/drawable/ic_snowflake.png b/app/src/main/res/drawable/ic_snowflake.png new file mode 100644 index 0000000000..3a554d917e Binary files /dev/null and b/app/src/main/res/drawable/ic_snowflake.png differ diff --git a/app/src/main/res/drawable/kindness.png b/app/src/main/res/drawable/kindness.png deleted file mode 100644 index cc379915b7..0000000000 Binary files a/app/src/main/res/drawable/kindness.png and /dev/null differ diff --git a/app/src/main/res/drawable/orbiesnoozing.png b/app/src/main/res/drawable/orbiesnoozing.png deleted file mode 100644 index 35eac329b7..0000000000 Binary files a/app/src/main/res/drawable/orbiesnoozing.png and /dev/null differ diff --git a/app/src/main/res/layout-land/fragment_kindness.xml b/app/src/main/res/layout-land/fragment_kindness.xml new file mode 100644 index 0000000000..6b857c8aa1 --- /dev/null +++ b/app/src/main/res/layout-land/fragment_kindness.xml @@ -0,0 +1,422 @@ + + + + + + + + + + + + + + + +