Commit 202bd2c9 authored by Mygod's avatar Mygod Committed by Max Lv

Add support for Android TV

This commit adds an app banner in Android TV and introduces several changes including refactoring AppManager to make all features accessible with D-pad navigation.

Currently this app has the exact same user interface on Android TV as on normal Android devices but one should create a TV-specific user interface in the future.

P.S. Only tested in Android emulator. More testing might be needed.
parent 3c14ff67
......@@ -11,6 +11,8 @@
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false"/>
<uses-feature android:name="android.software.leanback"
android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
......@@ -25,7 +27,8 @@
android:icon="@drawable/ic_launcher"
android:backupAgent=".ShadowsocksBackupAgent"
android:label="@string/app_name"
android:theme="@style/Theme.Material">
android:theme="@style/Theme.Material"
android:banner="@drawable/ic_start_connected">
<meta-data android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version"/>
......@@ -43,6 +46,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity>
......
......@@ -45,10 +45,10 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<ListView
<android.support.v7.widget.RecyclerView
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:id="@+id/applistview"
android:visibility="gone"/>
</FrameLayout>
</LinearLayout>
\ No newline at end of file
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:background="?android:attr/selectableItemBackground">
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:background="?android:attr/selectableItemBackground">
<ImageView
android:id="@+id/itemicon"
......@@ -15,20 +16,19 @@
android:paddingTop="2dp"
android:paddingBottom="2dp"/>
<TextView
android:id="@+id/itemtext"
<Switch
android:id="@+id/itemcheck"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="fill_parent"
android:gravity="center_vertical"
android:ellipsize="end"
android:textSize="18sp"/>
<Switch
android:id="@+id/itemcheck"
android:layout_width="wrap_content"
android:layout_height="fill_parent"
android:layout_marginLeft="8dp"
android:textSize="18sp"
android:textColor="?android:attr/textColorSecondary"
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="false"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"/>
</LinearLayout>
......@@ -3,11 +3,14 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/container"
android:background="?android:attr/selectableItemBackground"
android:paddingStart="20dp"
android:paddingEnd="?attr/dialogPreferredPadding"
android:paddingLeft="20dp"
android:paddingRight="?attr/dialogPreferredPadding">
android:paddingRight="?attr/dialogPreferredPadding"
android:focusable="true"
android:nextFocusRight="@+id/share">
<CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_width="0dp"
......@@ -30,5 +33,7 @@
android:layout_gravity="center_vertical"
android:src="@drawable/ic_social_share"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/share"/>
android:contentDescription="@string/share"
android:focusable="true"
android:nextFocusLeft="@+id/container"/>
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="80dp" android:maxWidth="80dp" android:gravity="center"
android:textSize="70sp" android:textColor="#ffffffff"
android:background="#99000088" android:padding="10dp"
android:visibility="invisible"/>
\ No newline at end of file
......@@ -89,7 +89,7 @@
<!-- profile -->
<string name="profile_manager_dialog">配置文件</string>
<string name="profile_manager_dialog_content">&#8226; 点击选择配置文件;\n&#8226; 滑动删除;\n&#8226;
<string name="profile_manager_dialog_content">&#8226; 点击选择配置文件;\n&#8226; 向左/右滑动或按左删除;\n&#8226;
长按拖动改变顺序。</string>
<string name="gotcha">好的</string>
<string name="copy_url">复制 URL</string>
......
......@@ -90,7 +90,7 @@
<!-- profile -->
<string name="profile_manager_dialog">設定檔</string>
<string name="profile_manager_dialog_content">&#8226; 輕觸選擇一個設定檔;\n&#8226;右撥動移除;\n&#8226;
<string name="profile_manager_dialog_content">&#8226; 輕觸選擇一個設定檔;\n&#8226;左/右撥動或按左移除;\n&#8226;
按住後上下撥動設定檔重新排序。</string>
<string name="gotcha">我懂了!</string>
<string name="copy_url">複製 URL</string>
......
......@@ -110,8 +110,8 @@
<!-- profile -->
<string name="profile_manager_dialog">Profiles</string>
<string name="profile_manager_dialog_content">&#8226; Tap to select a profile;\n&#8226; Swipe to remove;\n&#8226;
Long press to reorder profiles.</string>
<string name="profile_manager_dialog_content">&#8226; Tap to select a profile;\n&#8226; Swipe left/right or press left
to remove;\n&#8226; Long press to reorder profiles.</string>
<string name="gotcha">Gotcha!</string>
<string name="add_profile_dialog">Add this Shadowsocks Profile?</string>
<string name="add_profile_methods_scan_qr_code">Scan QR code</string>
......
......@@ -44,18 +44,13 @@ import java.util.concurrent.atomic.AtomicBoolean
import android.Manifest.permission
import android.content._
import android.content.pm.PackageManager
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.os.{Bundle, Handler}
import android.preference.PreferenceManager
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.support.v7.widget.Toolbar.OnMenuItemClickListener
import android.view.View.OnClickListener
import android.view.ViewGroup.LayoutParams
import android.support.v7.widget.{DefaultItemAnimator, LinearLayoutManager, RecyclerView, Toolbar}
import android.view._
import android.widget.AbsListView.OnScrollListener
import android.widget.CompoundButton.OnCheckedChangeListener
import android.widget._
import com.github.shadowsocks.utils.{Key, Utils}
......@@ -95,39 +90,56 @@ object AppManager {
}
}
class AppManager extends AppCompatActivity with OnCheckedChangeListener with OnClickListener
with OnMenuItemClickListener {
class AppManager extends AppCompatActivity with OnMenuItemClickListener {
import AppManager._
private var apps: Array[ProxiedApp] = _
private var proxiedApps: mutable.HashSet[String] = _
private var toolbar: Toolbar = _
private var appListView: ListView = _
private var loadingView: View = _
private var overlay: TextView = _
private var adapter: ListAdapter = _
private val appsLoading = new AtomicBoolean
private final class AppViewHolder(val view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener {
private val icon = itemView.findViewById(R.id.itemicon).asInstanceOf[ImageView]
private val check = itemView.findViewById(R.id.itemcheck).asInstanceOf[Switch]
private var app: ProxiedApp = _
itemView.setOnClickListener(this)
private def setProxied(pn: String, proxied: Boolean) = if (proxied) proxiedApps.add(pn) else proxiedApps.remove(pn)
private def proxied = proxiedApps.contains(app.packageName)
/** Called an application is check/unchecked */
def onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
val app: ProxiedApp = buttonView.getTag.asInstanceOf[ProxiedApp]
if (app != null) setProxied(app.packageName, isChecked)
saveAppSettings(this)
}
def bind(app: ProxiedApp) {
this.app = app
icon.setImageDrawable(app.icon)
check.setText(app.name)
check.setChecked(proxied)
}
def onClick(v: View) {
val switch = v.getTag.asInstanceOf[ListEntry].switch
val app: ProxiedApp = switch.getTag.asInstanceOf[ProxiedApp]
if (app != null) {
val proxied = !proxiedApps.contains(app.packageName)
setProxied(app.packageName, proxied)
switch.setChecked(proxied)
def onClick(v: View) {
if (proxied) {
proxiedApps.remove(app.packageName)
check.setChecked(false)
} else {
proxiedApps.add(app.packageName)
check.setChecked(true)
}
if (!appsLoading.get)
ShadowsocksApplication.settings.edit.putString(Key.proxied, proxiedApps.mkString("\n")).apply
}
saveAppSettings(this)
}
private final class AppsAdapter extends RecyclerView.Adapter[AppViewHolder] {
private val apps = getApps(getPackageManager).sortWith((a, b) => {
val aProxied = proxiedApps.contains(a.packageName)
if (aProxied ^ proxiedApps.contains(b.packageName)) aProxied else a.name.compareToIgnoreCase(b.name) < 0
})
def getItemCount = apps.length
def onBindViewHolder(vh: AppViewHolder, i: Int) = vh.bind(apps(i))
def onCreateViewHolder(vg: ViewGroup, i: Int) =
new AppViewHolder(LayoutInflater.from(vg.getContext).inflate(R.layout.layout_apps_item, vg, false))
}
private val proxiedApps = ShadowsocksApplication.settings.getString(Key.proxied, "").split('\n').to[mutable.HashSet]
private var toolbar: Toolbar = _
private var appListView: RecyclerView = _
private var loadingView: View = _
private val appsLoading = new AtomicBoolean
private var handler: Handler = null
override def onDestroy() {
instance = null
super.onDestroy()
......@@ -135,10 +147,6 @@ class AppManager extends AppCompatActivity with OnCheckedChangeListener with OnC
handler.removeCallbacksAndMessages(null)
handler = null
}
if (overlay != null) {
getWindowManager.removeViewImmediate(overlay)
overlay = null
}
}
def onMenuItemClick(item: MenuItem): Boolean = {
......@@ -200,44 +208,23 @@ class AppManager extends AppCompatActivity with OnCheckedChangeListener with OnC
toolbar.inflateMenu(R.menu.app_manager_menu)
toolbar.setOnMenuItemClickListener(this)
overlay = View.inflate(this, R.layout.overlay, null).asInstanceOf[TextView]
getWindowManager.addView(overlay, new
WindowManager.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, PixelFormat.TRANSLUCENT))
ShadowsocksApplication.settings.edit().putBoolean(Key.isProxyApps, true).commit()
findViewById(R.id.onSwitch).asInstanceOf[Switch]
.setOnCheckedChangeListener((button: CompoundButton, checked: Boolean) => {
.setOnCheckedChangeListener((_, checked) => {
ShadowsocksApplication.settings.edit().putBoolean(Key.isProxyApps, checked).commit()
finish()
})
val bypassSwitch = findViewById(R.id.bypassSwitch).asInstanceOf[Switch]
bypassSwitch.setOnCheckedChangeListener((button: CompoundButton, checked: Boolean) =>
bypassSwitch.setOnCheckedChangeListener((_, checked) =>
ShadowsocksApplication.settings.edit().putBoolean(Key.isBypassApps, checked).apply())
bypassSwitch.setChecked(ShadowsocksApplication.settings.getBoolean(Key.isBypassApps, false))
loadingView = findViewById(R.id.loading)
appListView = findViewById(R.id.applistview).asInstanceOf[ListView]
appListView.setOnScrollListener(new AbsListView.OnScrollListener {
var visible = false
def onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int,
totalItemCount: Int) {
if (visible) {
val name = apps(firstVisibleItem).name
overlay.setText(if (name != null && name.length > 1) name(0).toUpper.toString else "*")
overlay.setVisibility(View.VISIBLE)
}
}
def onScrollStateChanged(view: AbsListView, scrollState: Int) {
visible = true
if (scrollState == OnScrollListener.SCROLL_STATE_IDLE) {
overlay.setVisibility(View.INVISIBLE)
}
}
})
appListView = findViewById(R.id.applistview).asInstanceOf[RecyclerView]
appListView.setLayoutManager(new LinearLayoutManager(this))
appListView.setItemAnimator(new DefaultItemAnimator)
instance = this
loadAppsAsync()
}
......@@ -246,40 +233,10 @@ class AppManager extends AppCompatActivity with OnCheckedChangeListener with OnC
def loadAppsAsync() {
if (!appsLoading.compareAndSet(false, true)) return
ThrowableFuture {
var adapter: AppsAdapter = null
do {
proxiedApps = ShadowsocksApplication.settings.getString(Key.proxied, "").split('\n').to[mutable.HashSet]
appsLoading.set(true)
apps = getApps(getPackageManager).sortWith((a, b) => {
val aProxied = proxiedApps.contains(a.packageName)
if (aProxied ^ proxiedApps.contains(b.packageName)) aProxied else a.name.compareToIgnoreCase(b.name) < 0
})
adapter = new ArrayAdapter[ProxiedApp](this, R.layout.layout_apps_item, R.id.itemtext, apps) {
override def getView(position: Int, view: View, parent: ViewGroup): View = {
var convertView = view
var entry: ListEntry = null
if (convertView == null) {
convertView = getLayoutInflater.inflate(R.layout.layout_apps_item, parent, false)
entry = new ListEntry(convertView.findViewById(R.id.itemcheck).asInstanceOf[Switch],
convertView.findViewById(R.id.itemtext).asInstanceOf[TextView],
convertView.findViewById(R.id.itemicon).asInstanceOf[ImageView])
convertView.setOnClickListener(AppManager.this)
convertView.setTag(entry)
entry.switch.setOnCheckedChangeListener(AppManager.this)
} else {
entry = convertView.getTag.asInstanceOf[ListEntry]
}
val app: ProxiedApp = apps(position)
entry.text.setText(app.name)
entry.icon.setImageDrawable(app.icon)
val switch = entry.switch
switch.setTag(app)
switch.setChecked(proxiedApps.contains(app.packageName))
entry.text.setTag(switch)
convertView
}
}
adapter = new AppsAdapter
} while (!appsLoading.compareAndSet(true, false))
handler.post(() => {
appListView.setAdapter(adapter)
......@@ -288,12 +245,6 @@ class AppManager extends AppCompatActivity with OnCheckedChangeListener with OnC
}
}
def saveAppSettings(context: Context) {
if (!appsLoading.get) ShadowsocksApplication.settings.edit.putString(Key.proxied, proxiedApps.mkString("\n")).apply
}
var handler: Handler = null
override def onKeyUp(keyCode: Int, event: KeyEvent) = keyCode match {
case KeyEvent.KEYCODE_MENU =>
if (toolbar.isOverflowMenuShowing) toolbar.hideOverflowMenu else toolbar.showOverflowMenu
......
......@@ -15,9 +15,9 @@ import android.support.v7.widget.helper.ItemTouchHelper.SimpleCallback
import android.support.v7.widget.{DefaultItemAnimator, LinearLayoutManager, RecyclerView, Toolbar}
import android.text.style.TextAppearanceSpan
import android.text.{SpannableStringBuilder, Spanned, TextUtils}
import android.view.{LayoutInflater, MenuItem, View, ViewGroup}
import android.view._
import android.widget.{CheckedTextView, ImageView, LinearLayout, Toast}
import com.github.clans.fab.{FloatingActionMenu, FloatingActionButton}
import com.github.clans.fab.{FloatingActionButton, FloatingActionMenu}
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.utils.{Key, Parser, TrafficMonitor, Utils}
......@@ -27,12 +27,14 @@ import net.glxn.qrgen.android.QRCode
import scala.collection.mutable.ArrayBuffer
class ProfileManagerActivity extends AppCompatActivity with OnMenuItemClickListener with ServiceBoundContext with View.OnClickListener
with CreateNdefMessageCallback {
private class ProfileViewHolder(val view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener {
final class ProfileManagerActivity extends AppCompatActivity with OnMenuItemClickListener with ServiceBoundContext
with View.OnClickListener with CreateNdefMessageCallback {
private final class ProfileViewHolder(val view: View) extends RecyclerView.ViewHolder(view)
with View.OnClickListener with View.OnKeyListener {
var item: Profile = _
private val text = itemView.findViewById(android.R.id.text1).asInstanceOf[CheckedTextView]
itemView.setOnClickListener(this)
itemView.setOnKeyListener(this)
{
val shareBtn = itemView.findViewById(R.id.share)
......@@ -107,6 +109,15 @@ class ProfileManagerActivity extends AppCompatActivity with OnMenuItemClickListe
ShadowsocksApplication.switchProfile(item.id)
finish
}
def onKey(v: View, keyCode: Int, event: KeyEvent) = if (event.getAction == KeyEvent.ACTION_UP) keyCode match {
case KeyEvent.KEYCODE_DPAD_LEFT =>
val index = getAdapterPosition
profilesAdapter.remove(index)
undoManager.remove(index, item)
true
case _ => false
} else false
}
private class ProfilesAdapter extends RecyclerView.Adapter[ProfileViewHolder] {
......
......@@ -134,9 +134,9 @@ class Shadowsocks
preferences.setEnabled(false)
stat.setVisibility(View.VISIBLE)
if (ShadowsocksApplication.isVpnEnabled) {
connectionTest.setVisibility(View.VISIBLE)
connectionTest.setText(getString(R.string.connection_test_pending))
} else connectionTest.setVisibility(View.GONE)
connectionTestText.setVisibility(View.VISIBLE)
connectionTestText.setText(getString(R.string.connection_test_pending))
} else connectionTestText.setVisibility(View.GONE)
case State.STOPPED =>
fab.setBackgroundTintList(greyTint)
fabProgressCircle.postDelayed(hideCircle, 1000)
......@@ -217,7 +217,7 @@ class Shadowsocks
private var testCount: Int = _
private lazy val stat = findViewById(R.id.stat)
private lazy val connectionTest = findViewById(R.id.connection_test).asInstanceOf[TextView]
private var connectionTestText: TextView = _
private var txText: TextView = _
private var rxText: TextView = _
private var txRateText: TextView = _
......@@ -371,6 +371,7 @@ class Shadowsocks
val field = classOf[Toolbar].getDeclaredField("mTitleTextView")
field.setAccessible(true)
val title = field.get(toolbar).asInstanceOf[TextView]
title.setFocusable(true)
title.setOnClickListener(_ => startActivity(new Intent(this, classOf[ProfileManagerActivity])))
val typedArray = obtainStyledAttributes(Array(R.attr.selectableItemBackgroundBorderless))
title.setBackgroundResource(typedArray.getResourceId(0, 0))
......@@ -378,11 +379,12 @@ class Shadowsocks
val tf = Typefaces.get(this, "fonts/Iceland.ttf")
if (tf != null) title.setTypeface(tf)
val connectionTestText = findViewById(R.id.connection_test).asInstanceOf[TextView]
connectionTestText = findViewById(R.id.connection_test).asInstanceOf[TextView]
txText = findViewById(R.id.tx).asInstanceOf[TextView]
txRateText = findViewById(R.id.txRate).asInstanceOf[TextView]
rxText = findViewById(R.id.rx).asInstanceOf[TextView]
rxRateText = findViewById(R.id.rxRate).asInstanceOf[TextView]
connectionTestText.setFocusable(true)
connectionTestText.setOnClickListener(_ => {
val id = synchronized {
testCount += 1
......@@ -472,9 +474,9 @@ class Shadowsocks
fabProgressCircle.postDelayed(hideCircle, 100)
stat.setVisibility(View.VISIBLE)
if (ShadowsocksApplication.isVpnEnabled) {
connectionTest.setVisibility(View.VISIBLE)
connectionTest.setText(getString(R.string.connection_test_pending))
} else connectionTest.setVisibility(View.GONE)
connectionTestText.setVisibility(View.VISIBLE)
connectionTestText.setText(getString(R.string.connection_test_pending))
} else connectionTestText.setVisibility(View.GONE)
case State.STOPPING =>
fab.setBackgroundTintList(greyTint)
serviceStarted = false
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment