mirror of
https://github.com/bmlzootown/Hydravion-AndroidTV.git
synced 2025-12-30 10:09:46 -06:00
7
.gitignore
vendored
7
.gitignore
vendored
@@ -53,6 +53,10 @@ captures/
|
||||
.idea/navEditor.xml
|
||||
.idea/deploymentTargetDropDown.xml
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
app/release/
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
@@ -88,4 +92,5 @@ lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
*.hprof
|
||||
floatplane-openapi-specification-trimmed.json
|
||||
|
||||
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="17" />
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -18,7 +18,8 @@
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -2,16 +2,16 @@ apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
compileSdkVersion 34
|
||||
buildToolsVersion "30.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "ml.bmlzootown.hydravion"
|
||||
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 33
|
||||
versionCode 2
|
||||
versionName "1.5.2"
|
||||
targetSdkVersion 34
|
||||
versionCode 6
|
||||
versionName "1.5.6"
|
||||
|
||||
}
|
||||
|
||||
@@ -78,4 +78,11 @@ dependencies {
|
||||
|
||||
// Version Compare
|
||||
implementation("io.github.g00fy2:versioncompare:1.5.0")
|
||||
|
||||
// Local HTTP Server
|
||||
implementation 'org.nanohttpd:nanohttpd:2.3.1'
|
||||
|
||||
// QR Code Generation
|
||||
implementation 'com.google.zxing:core:3.5.2'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@@ -58,10 +59,9 @@
|
||||
<activity
|
||||
android:name=".playback.PlaybackActivity"
|
||||
android:exported="false" />
|
||||
<!-- Not sure if this one needs to be exported or not -->
|
||||
<activity
|
||||
android:name=".authenticate.LoginActivity"
|
||||
android:exported="true"
|
||||
android:name=".authenticate.QrLoginActivity"
|
||||
android:exported="false"
|
||||
android:noHistory="true" />
|
||||
</application>
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ package ml.bmlzootown.hydravion
|
||||
|
||||
object Constants {
|
||||
|
||||
const val PREF_SAIL_SSID = "sails.sid"
|
||||
// OAuth2 / OpenID Connect token preferences
|
||||
const val PREF_ACCESS_TOKEN = "access_token"
|
||||
const val PREF_REFRESH_TOKEN = "refresh_token"
|
||||
const val PREF_TOKEN_EXPIRES_AT = "token_expires_at"
|
||||
|
||||
const val REQ_CODE_DETAIL = 1
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package ml.bmlzootown.hydravion.authenticate
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.android.volley.Request
|
||||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.toolbox.StringRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import ml.bmlzootown.hydravion.Constants
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
// Centralized helper for managing OAuth tokens and automatic access-token refresh.
|
||||
class AuthManager private constructor(
|
||||
private val context: Context,
|
||||
private val prefs: SharedPreferences
|
||||
) {
|
||||
|
||||
private val queue: RequestQueue = Volley.newRequestQueue(context.applicationContext)
|
||||
private val TAG = "AuthManager"
|
||||
|
||||
// Cache for validated token to avoid re-validation on every request
|
||||
@Volatile
|
||||
private var cachedToken: String? = null
|
||||
@Volatile
|
||||
private var cacheValidUntil: Long = 0
|
||||
|
||||
// Queue for concurrent token validation requests
|
||||
private val pendingCallbacks = ConcurrentLinkedQueue<Pair<(String) -> Unit, (() -> Unit)?>>()
|
||||
@Volatile
|
||||
private var isRefreshing = false
|
||||
|
||||
private val tokenEndpoint =
|
||||
"https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token"
|
||||
private val clientId = "hydravion"
|
||||
|
||||
fun getAccessToken(): String =
|
||||
prefs.getString(Constants.PREF_ACCESS_TOKEN, "") ?: ""
|
||||
|
||||
fun getRefreshToken(): String =
|
||||
prefs.getString(Constants.PREF_REFRESH_TOKEN, "") ?: ""
|
||||
|
||||
private fun getExpiresAt(): Long =
|
||||
prefs.getLong(Constants.PREF_TOKEN_EXPIRES_AT, 0L)
|
||||
|
||||
fun clearTokens() {
|
||||
prefs.edit()
|
||||
.remove(Constants.PREF_ACCESS_TOKEN)
|
||||
.remove(Constants.PREF_REFRESH_TOKEN)
|
||||
.remove(Constants.PREF_TOKEN_EXPIRES_AT)
|
||||
.commit()
|
||||
// Clear cache when tokens are cleared
|
||||
cachedToken = null
|
||||
cacheValidUntil = 0
|
||||
}
|
||||
|
||||
private fun isAccessTokenValid(): Boolean {
|
||||
val token = getAccessToken()
|
||||
if (token.isEmpty()) {
|
||||
Log.d(TAG, "Access token is empty")
|
||||
return false
|
||||
}
|
||||
|
||||
// Add 60s of leeway to avoid race with server-side expiry.
|
||||
val now = System.currentTimeMillis()
|
||||
val expiresAt = getExpiresAt()
|
||||
val isValid = now + 60_000L < expiresAt
|
||||
if (!isValid) {
|
||||
val timeUntilExpiry = expiresAt - now
|
||||
Log.d(TAG, "Access token expired or expiring soon. Time until expiry: ${timeUntilExpiry / 1000}s")
|
||||
}
|
||||
return isValid
|
||||
}
|
||||
|
||||
// Ensures a valid access token, refreshing with the refresh token when necessary.
|
||||
// - On success: invokes [onToken] with a non-empty access token.
|
||||
// - On failure: clears stored tokens and invokes [onFailure].
|
||||
// - Batches concurrent requests to avoid multiple simultaneous refresh attempts.
|
||||
fun withValidAccessToken(
|
||||
onToken: (String) -> Unit,
|
||||
onFailure: (() -> Unit)? = null
|
||||
) {
|
||||
// Check cache first (valid for 30 seconds to reduce validation overhead)
|
||||
val now = System.currentTimeMillis()
|
||||
if (cachedToken != null && now < cacheValidUntil) {
|
||||
Log.d(TAG, "Using cached valid token")
|
||||
onToken(cachedToken!!)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if token is valid in storage
|
||||
if (isAccessTokenValid()) {
|
||||
val token = getAccessToken()
|
||||
// Cache the token for 30 seconds
|
||||
cachedToken = token
|
||||
cacheValidUntil = now + 30_000L
|
||||
Log.d(TAG, "Access token is valid, using existing token (cached for 30s)")
|
||||
onToken(token)
|
||||
return
|
||||
}
|
||||
|
||||
// If already refreshing, queue this callback to be notified when refresh completes
|
||||
if (isRefreshing) {
|
||||
Log.d(TAG, "Token refresh in progress, queuing callback")
|
||||
pendingCallbacks.add(Pair(onToken, onFailure))
|
||||
return
|
||||
}
|
||||
|
||||
// Start refresh process
|
||||
isRefreshing = true
|
||||
Log.d(TAG, "Access token expired or invalid, attempting refresh")
|
||||
val refreshToken = getRefreshToken()
|
||||
if (refreshToken.isEmpty()) {
|
||||
Log.w(TAG, "Refresh token is empty, cannot refresh. Clearing tokens.")
|
||||
clearTokens()
|
||||
|
||||
// Notify all pending callbacks before returning
|
||||
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
|
||||
while (pendingCallbacks.isNotEmpty()) {
|
||||
pendingCallbacks.poll()?.let { callbacks.add(it) }
|
||||
}
|
||||
isRefreshing = false
|
||||
|
||||
// Notify current caller
|
||||
onFailure?.invoke()
|
||||
// Notify all queued callbacks
|
||||
callbacks.forEach { (_, failureCallback) ->
|
||||
failureCallback?.invoke()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Refreshing access token using refresh token")
|
||||
|
||||
val request = object : StringRequest(
|
||||
Method.POST,
|
||||
tokenEndpoint,
|
||||
{ response ->
|
||||
try {
|
||||
val json = JSONObject(response)
|
||||
if (json.has("access_token")) {
|
||||
val newAccessToken = json.getString("access_token")
|
||||
val newRefreshToken = json.optString("refresh_token", refreshToken)
|
||||
val expiresIn = json.optLong("expires_in", 1800L)
|
||||
val expiresAt = System.currentTimeMillis() + expiresIn * 1000L
|
||||
|
||||
Log.d(TAG, "Token refresh successful. New token expires in ${expiresIn}s")
|
||||
if (newRefreshToken != refreshToken) {
|
||||
Log.d(TAG, "Received new refresh token")
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.putString(Constants.PREF_ACCESS_TOKEN, newAccessToken)
|
||||
.putString(Constants.PREF_REFRESH_TOKEN, newRefreshToken)
|
||||
.putLong(Constants.PREF_TOKEN_EXPIRES_AT, expiresAt)
|
||||
.commit()
|
||||
|
||||
// Cache the new token
|
||||
cachedToken = newAccessToken
|
||||
cacheValidUntil = System.currentTimeMillis() + 30_000L
|
||||
|
||||
// Notify all pending callbacks
|
||||
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
|
||||
while (pendingCallbacks.isNotEmpty()) {
|
||||
pendingCallbacks.poll()?.let { callbacks.add(it) }
|
||||
}
|
||||
isRefreshing = false
|
||||
|
||||
onToken(newAccessToken)
|
||||
callbacks.forEach { (tokenCallback, _) ->
|
||||
tokenCallback(newAccessToken)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Token refresh response missing access_token")
|
||||
clearTokens()
|
||||
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
|
||||
while (pendingCallbacks.isNotEmpty()) {
|
||||
pendingCallbacks.poll()?.let { callbacks.add(it) }
|
||||
}
|
||||
isRefreshing = false
|
||||
onFailure?.invoke()
|
||||
callbacks.forEach { (_, failureCallback) ->
|
||||
failureCallback?.invoke()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception parsing token refresh response", e)
|
||||
clearTokens()
|
||||
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
|
||||
while (pendingCallbacks.isNotEmpty()) {
|
||||
pendingCallbacks.poll()?.let { callbacks.add(it) }
|
||||
}
|
||||
isRefreshing = false
|
||||
onFailure?.invoke()
|
||||
callbacks.forEach { (_, failureCallback) ->
|
||||
failureCallback?.invoke()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
Log.e(TAG, "Token refresh request failed: ${error.message}")
|
||||
clearTokens()
|
||||
val callbacks = mutableListOf<Pair<(String) -> Unit, (() -> Unit)?>>()
|
||||
while (pendingCallbacks.isNotEmpty()) {
|
||||
pendingCallbacks.poll()?.let { callbacks.add(it) }
|
||||
}
|
||||
isRefreshing = false
|
||||
onFailure?.invoke()
|
||||
callbacks.forEach { (_, failureCallback) ->
|
||||
failureCallback?.invoke()
|
||||
}
|
||||
}
|
||||
) {
|
||||
override fun getParams(): MutableMap<String, String> =
|
||||
mutableMapOf(
|
||||
"grant_type" to "refresh_token",
|
||||
"client_id" to clientId,
|
||||
"refresh_token" to refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
queue.add(request)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AuthManager? = null
|
||||
|
||||
fun getInstance(context: Context, prefs: SharedPreferences): AuthManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: AuthManager(context.applicationContext, prefs).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package ml.bmlzootown.hydravion.authenticate;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.text.InputType;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.android.volley.VolleyError;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
import ml.bmlzootown.hydravion.R;
|
||||
import ml.bmlzootown.hydravion.browse.MainFragment;
|
||||
import ml.bmlzootown.hydravion.models.LoginResponse;
|
||||
|
||||
public class LoginActivity extends Activity {
|
||||
|
||||
private TextInputEditText username;
|
||||
private TextInputEditText password;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
username = findViewById(R.id.username);
|
||||
password = findViewById(R.id.password);
|
||||
}
|
||||
|
||||
public void login(@Nullable View view) {
|
||||
if (username != null && password != null) {
|
||||
if (username.length() > 0 || password.length() > 0) {
|
||||
String user = username.getText().toString();
|
||||
String pass = password.getText().toString();
|
||||
hideSoftKeyboard(view);
|
||||
doLogin(user, pass);
|
||||
} else {
|
||||
Toast.makeText(this, "Incorrect username/password!", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(this, "Incorrect username/password!", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void doLogin(String username, String password) {
|
||||
LoginRequestTask rt = new LoginRequestTask(this.getApplicationContext());
|
||||
rt.sendRequest(username, password, new LoginRequestTask.VolleyCallback() {
|
||||
@Override
|
||||
public void onSuccess(ArrayList<String> cookies, String response) {
|
||||
if (response.length() < 19) {
|
||||
MainFragment.dLog("RESPONSE", "Possible 2FA");
|
||||
Gson gson = new Gson();
|
||||
LoginResponse res = gson.fromJson(response, LoginResponse.class);
|
||||
if (res.getNeeds2FA()) {
|
||||
MainFragment.dLog("RESPONSE", "NEEDS 2FA");
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(LoginActivity.this);
|
||||
builder.setTitle("2FA Token");
|
||||
final EditText input = new EditText(LoginActivity.this);
|
||||
input.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
builder.setView(input);
|
||||
|
||||
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
String token = input.getText().toString();
|
||||
LoginRequestTask twoFA = new LoginRequestTask(LoginActivity.this);
|
||||
twoFA.sendRequest(token, cookies, new LoginRequestTask.TwoFACallback() {
|
||||
@Override
|
||||
public void onSuccess(ArrayList<String> string) {
|
||||
String sailssid = string.get(0);
|
||||
ArrayList<String> newCookies = new ArrayList<>();
|
||||
for (String c : cookies) {
|
||||
if (!c.contains("sails.sid")) {
|
||||
newCookies.add(c);
|
||||
}
|
||||
}
|
||||
newCookies.add(sailssid);
|
||||
Intent intent = new Intent();
|
||||
intent.putStringArrayListExtra("cookies", newCookies);
|
||||
setResult(1, intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(VolleyError ve) {
|
||||
Toast.makeText(LoginActivity.this, "Incorrect 2FA token!", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
builder.show();
|
||||
}
|
||||
} else {
|
||||
Intent intent = new Intent();
|
||||
intent.putStringArrayListExtra("cookies", cookies);
|
||||
setResult(1, intent);
|
||||
finish();
|
||||
}
|
||||
MainFragment.dLog("response", response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError() {
|
||||
Toast.makeText(getApplicationContext(), "Incorrect username/password!", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void hideSoftKeyboard(View view) {
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package ml.bmlzootown.hydravion.authenticate;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.android.volley.Header;
|
||||
import com.android.volley.NetworkResponse;
|
||||
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.StringRequest;
|
||||
import com.android.volley.toolbox.Volley;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LoginRequestTask {
|
||||
private Context context;
|
||||
private ArrayList<String> cookies;
|
||||
|
||||
private static final String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
|
||||
private static final String userAgent = String.format("Hydravion %s (AndroidTV), CFNetwork", version);
|
||||
|
||||
public LoginRequestTask(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void sendRequest(String user, String pass, final LoginRequestTask.VolleyCallback callback) {
|
||||
String uri = "https://www.floatplane.com/api/auth/login";
|
||||
RequestQueue queue = Volley.newRequestQueue(this.context);
|
||||
StringRequest stringRequest = new StringRequest(Request.Method.POST, uri,
|
||||
response -> callback.onSuccess(cookies, response), error -> {
|
||||
error.printStackTrace();
|
||||
callback.onError();
|
||||
}) {
|
||||
@Override
|
||||
protected Response<String> parseNetworkResponse(NetworkResponse response) {
|
||||
List<Header> headers = response.allHeaders;
|
||||
ArrayList<String> cs = new ArrayList<>();
|
||||
for (Header header : headers) {
|
||||
if (header.getName().equalsIgnoreCase("Set-Cookie")) {
|
||||
cs.add(header.getValue().split(";")[0]);
|
||||
}
|
||||
}
|
||||
cookies = cs;
|
||||
return super.parseNetworkResponse(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getHeaders() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("Accept", "application/json");
|
||||
params.put("User-Agent", userAgent);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> getParams() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("username", user);
|
||||
params.put("password", pass);
|
||||
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
queue.add(stringRequest);
|
||||
}
|
||||
|
||||
public void sendRequest(String token, ArrayList<String> cookiez, final LoginRequestTask.TwoFACallback callback) {
|
||||
String uri = "https://www.floatplane.com/api/v2/auth/checkFor2faLogin";
|
||||
RequestQueue queue = Volley.newRequestQueue(this.context);
|
||||
StringRequest stringRequest = new StringRequest(Request.Method.POST, uri,
|
||||
response -> callback.onSuccess(cookies), error -> {
|
||||
error.printStackTrace();
|
||||
callback.onError(error);
|
||||
}) {
|
||||
@Override
|
||||
protected Response<String> parseNetworkResponse(NetworkResponse response) {
|
||||
List<Header> headers = response.allHeaders;
|
||||
ArrayList<String> cs = new ArrayList<>();
|
||||
for (Header header : headers) {
|
||||
if (header.getName().equalsIgnoreCase("Set-Cookie")) {
|
||||
cs.add(header.getValue().split(";")[0]);
|
||||
}
|
||||
}
|
||||
cookies = cs;
|
||||
return super.parseNetworkResponse(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getHeaders() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
StringBuilder cs = new StringBuilder();
|
||||
for (String c : cookiez) {
|
||||
cs.append(c).append(";");
|
||||
}
|
||||
params.put("Cookie", cs.toString());
|
||||
params.put("User-Agent", userAgent);
|
||||
params.put("Accept", "application/json");
|
||||
return params;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> getParams() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("token", token);
|
||||
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
queue.add(stringRequest);
|
||||
}
|
||||
|
||||
public interface TwoFACallback {
|
||||
void onSuccess(ArrayList<String> string);
|
||||
|
||||
void onError(VolleyError ve);
|
||||
}
|
||||
|
||||
public interface VolleyCallback {
|
||||
void onSuccess(ArrayList<String> string, String response);
|
||||
|
||||
void onError();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,14 +20,15 @@ public class LogoutRequestTask {
|
||||
private Context context;
|
||||
|
||||
private static final String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
|
||||
private static final String userAgent = String.format("Hydravion %s (AndroidTV), CFNetwork", version);
|
||||
private static final String userAgent = String.format("Hydravion %s (AndroidTV)", version);
|
||||
|
||||
public LogoutRequestTask(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void logout(String cookies, final LogoutRequestTask.VolleyCallback callback) {
|
||||
String uri = "https://www.floatplane.com/api/auth/logout";
|
||||
public void logout(String accessToken, final LogoutRequestTask.VolleyCallback callback) {
|
||||
// Revoke access token via revocation endpoint
|
||||
String uri = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/revoke";
|
||||
RequestQueue queue = Volley.newRequestQueue(this.context);
|
||||
StringRequest stringRequest = new StringRequest(Request.Method.POST, uri,
|
||||
callback::onSuccess, error -> {
|
||||
@@ -37,11 +38,18 @@ public class LogoutRequestTask {
|
||||
@Override
|
||||
public Map<String, String> getHeaders() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("Cookie", cookies);
|
||||
params.put("Accept", "application/json");
|
||||
params.put("User-Agent", userAgent);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> getParams() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("client_id", "hydravion");
|
||||
params.put("token", accessToken);
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
queue.add(stringRequest);
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package ml.bmlzootown.hydravion.authenticate;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.util.Log;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.journeyapps.barcodescanner.BarcodeEncoder;
|
||||
|
||||
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.StringRequest;
|
||||
import com.android.volley.toolbox.Volley;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import ml.bmlzootown.hydravion.R;
|
||||
import ml.bmlzootown.hydravion.browse.MainFragment;
|
||||
|
||||
public class QrLoginActivity extends Activity {
|
||||
private static final String TAG = "QrLoginActivity";
|
||||
private static final String CLIENT_ID = "hydravion";
|
||||
private static final String SCOPE = "openid videos:watch account:read";
|
||||
private static final String DEVICE_AUTH_ENDPOINT = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device";
|
||||
private static final String TOKEN_ENDPOINT = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token";
|
||||
|
||||
private ImageView qrCodeView;
|
||||
private TextView statusTextView;
|
||||
private TextView userCodeDisplay;
|
||||
private TextView instructionsText;
|
||||
private TextView cookieSteps;
|
||||
private TextView timerText;
|
||||
private RequestQueue requestQueue;
|
||||
|
||||
private String deviceCode;
|
||||
private String userCode;
|
||||
private String verificationUri;
|
||||
private String verificationUriComplete;
|
||||
private long expiresAtMs;
|
||||
private long pollIntervalMs = 5000L;
|
||||
private android.os.Handler handler;
|
||||
private final Runnable pollRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
pollForToken();
|
||||
}
|
||||
};
|
||||
private final Runnable timerRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
updateTimer();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_qr_login);
|
||||
|
||||
qrCodeView = findViewById(R.id.qr_code);
|
||||
statusTextView = findViewById(R.id.status_text);
|
||||
userCodeDisplay = findViewById(R.id.user_code_display);
|
||||
instructionsText = findViewById(R.id.instructions_text);
|
||||
cookieSteps = findViewById(R.id.cookie_steps);
|
||||
timerText = findViewById(R.id.timer_text);
|
||||
requestQueue = Volley.newRequestQueue(this);
|
||||
handler = new android.os.Handler();
|
||||
|
||||
startDeviceAuthorization();
|
||||
}
|
||||
|
||||
private void startDeviceAuthorization() {
|
||||
statusTextView.setText("Requesting login code...");
|
||||
|
||||
StringRequest request = new StringRequest(
|
||||
Request.Method.POST,
|
||||
DEVICE_AUTH_ENDPOINT,
|
||||
this::handleDeviceAuthResponse,
|
||||
error -> {
|
||||
Log.e(TAG, "Device authorization error", error);
|
||||
Toast.makeText(this, "Failed to start device login.", Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
) {
|
||||
@Override
|
||||
protected Map<String, String> getParams() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("client_id", CLIENT_ID);
|
||||
params.put("scope", SCOPE);
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
requestQueue.add(request);
|
||||
}
|
||||
|
||||
private void handleDeviceAuthResponse(String response) {
|
||||
try {
|
||||
JSONObject json = new JSONObject(response);
|
||||
deviceCode = json.getString("device_code");
|
||||
userCode = json.getString("user_code");
|
||||
verificationUri = json.getString("verification_uri");
|
||||
verificationUriComplete = json.optString("verification_uri_complete", verificationUri);
|
||||
long expiresIn = json.optLong("expires_in", 600L);
|
||||
long interval = json.optLong("interval", 5L);
|
||||
|
||||
expiresAtMs = System.currentTimeMillis() + expiresIn * 1000L;
|
||||
pollIntervalMs = interval * 1000L;
|
||||
|
||||
// Generate QR code with verification_uri_complete (on-device, no external service)
|
||||
generateQRCode(verificationUriComplete);
|
||||
|
||||
// Display user code in highlighted area
|
||||
userCodeDisplay.setText(userCode);
|
||||
|
||||
// Update instructions text with bold floatplane.com/link
|
||||
updateInstructionsWithBoldUrl();
|
||||
|
||||
statusTextView.setText("Waiting for you to complete login on another device...");
|
||||
|
||||
// Start timer countdown
|
||||
handler.post(timerRunnable);
|
||||
|
||||
// Start polling token endpoint
|
||||
handler.postDelayed(pollRunnable, pollIntervalMs);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Failed to parse device auth response", e);
|
||||
Toast.makeText(this, "Invalid response from login server.", Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
private void generateQRCode(String url) {
|
||||
try {
|
||||
// Generate QR code on-device using local library (no external service)
|
||||
BarcodeEncoder barcodeEncoder = new BarcodeEncoder();
|
||||
// Generate bitmap - use size that matches view (280dp = ~840px at 3x density)
|
||||
Bitmap originalBitmap = barcodeEncoder.encodeBitmap(url, BarcodeFormat.QR_CODE, 840, 840);
|
||||
|
||||
// Crop white borders to remove quiet zone padding
|
||||
Bitmap croppedBitmap = cropWhiteBorders(originalBitmap);
|
||||
|
||||
// Recycle the original large bitmap if a new cropped bitmap was created
|
||||
if (croppedBitmap != originalBitmap && !originalBitmap.isRecycled()) {
|
||||
originalBitmap.recycle();
|
||||
}
|
||||
|
||||
qrCodeView.setImageBitmap(croppedBitmap);
|
||||
} catch (WriterException e) {
|
||||
Log.e(TAG, "Error generating QR code", e);
|
||||
Toast.makeText(this, "Failed to generate QR code", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap cropWhiteBorders(Bitmap bitmap) {
|
||||
int width = bitmap.getWidth();
|
||||
int height = bitmap.getHeight();
|
||||
|
||||
// Find top border
|
||||
int top = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
boolean hasNonWhite = false;
|
||||
for (int x = 0; x < width; x++) {
|
||||
int pixel = bitmap.getPixel(x, y);
|
||||
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
|
||||
hasNonWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonWhite) {
|
||||
top = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find bottom border
|
||||
int bottom = height - 1;
|
||||
for (int y = height - 1; y >= 0; y--) {
|
||||
boolean hasNonWhite = false;
|
||||
for (int x = 0; x < width; x++) {
|
||||
int pixel = bitmap.getPixel(x, y);
|
||||
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
|
||||
hasNonWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonWhite) {
|
||||
bottom = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find left border
|
||||
int left = 0;
|
||||
for (int x = 0; x < width; x++) {
|
||||
boolean hasNonWhite = false;
|
||||
for (int y = top; y <= bottom; y++) {
|
||||
int pixel = bitmap.getPixel(x, y);
|
||||
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
|
||||
hasNonWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonWhite) {
|
||||
left = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find right border
|
||||
int right = width - 1;
|
||||
for (int x = width - 1; x >= 0; x--) {
|
||||
boolean hasNonWhite = false;
|
||||
for (int y = top; y <= bottom; y++) {
|
||||
int pixel = bitmap.getPixel(x, y);
|
||||
if ((pixel & 0x00FFFFFF) != 0x00FFFFFF) { // Not white
|
||||
hasNonWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonWhite) {
|
||||
right = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Crop to actual QR code bounds
|
||||
if (left < right && top < bottom) {
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1);
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private void updateTimer() {
|
||||
if (expiresAtMs == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
long remainingMs = expiresAtMs - System.currentTimeMillis();
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
// Timer expired, refresh the QR code and user code
|
||||
timerText.setText("Time Remaining: 0:00");
|
||||
statusTextView.setText("Login code expired. Refreshing...");
|
||||
handler.removeCallbacks(pollRunnable);
|
||||
handler.removeCallbacks(timerRunnable);
|
||||
startDeviceAuthorization();
|
||||
return;
|
||||
}
|
||||
|
||||
long remainingSeconds = remainingMs / 1000L;
|
||||
long minutes = remainingSeconds / 60L;
|
||||
long seconds = remainingSeconds % 60L;
|
||||
|
||||
String timerString = String.format("Time Remaining: %d:%02d", minutes, seconds);
|
||||
timerText.setText(timerString);
|
||||
|
||||
// Update timer every second
|
||||
handler.postDelayed(timerRunnable, 1000L);
|
||||
}
|
||||
|
||||
private void updateInstructionsWithBoldUrl() {
|
||||
// Bold "floatplane.com/link" in instructions text
|
||||
String instructions = instructionsText.getText().toString();
|
||||
SpannableString spannable = new SpannableString(instructions);
|
||||
int start = instructions.indexOf("floatplane.com/link");
|
||||
if (start >= 0) {
|
||||
int end = start + "floatplane.com/link".length();
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
instructionsText.setText(spannable);
|
||||
|
||||
// Bold "floatplane.com/link" in steps text
|
||||
String steps = cookieSteps.getText().toString();
|
||||
SpannableString stepsSpannable = new SpannableString(steps);
|
||||
int stepsStart = steps.indexOf("floatplane.com/link");
|
||||
if (stepsStart >= 0) {
|
||||
int stepsEnd = stepsStart + "floatplane.com/link".length();
|
||||
stepsSpannable.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), stepsStart, stepsEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
cookieSteps.setText(stepsSpannable);
|
||||
}
|
||||
|
||||
private void pollForToken() {
|
||||
if (deviceCode == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() >= expiresAtMs) {
|
||||
// Timer will handle the refresh automatically
|
||||
return;
|
||||
}
|
||||
|
||||
StringRequest request = new StringRequest(
|
||||
Request.Method.POST,
|
||||
TOKEN_ENDPOINT,
|
||||
this::handleTokenResponse,
|
||||
error -> {
|
||||
Log.e(TAG, "Token polling error", error);
|
||||
// Keep polling on transient errors
|
||||
handler.postDelayed(pollRunnable, pollIntervalMs);
|
||||
}
|
||||
) {
|
||||
@Override
|
||||
protected Map<String, String> getParams() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
params.put("client_id", CLIENT_ID);
|
||||
params.put("device_code", deviceCode);
|
||||
return params;
|
||||
}
|
||||
};
|
||||
|
||||
requestQueue.add(request);
|
||||
}
|
||||
|
||||
private void handleTokenResponse(String response) {
|
||||
try {
|
||||
JSONObject json = new JSONObject(response);
|
||||
if (json.has("error")) {
|
||||
String error = json.getString("error");
|
||||
if ("authorization_pending".equals(error)) {
|
||||
// keep polling
|
||||
handler.postDelayed(pollRunnable, pollIntervalMs);
|
||||
return;
|
||||
} else if ("slow_down".equals(error)) {
|
||||
pollIntervalMs += 2000L;
|
||||
handler.postDelayed(pollRunnable, pollIntervalMs);
|
||||
return;
|
||||
} else if ("expired_token".equals(error)) {
|
||||
statusTextView.setText("Login code expired. Please try again.");
|
||||
return;
|
||||
} else {
|
||||
statusTextView.setText("Login failed: " + error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String accessToken = json.getString("access_token");
|
||||
String refreshToken = json.optString("refresh_token", "");
|
||||
long expiresIn = json.optLong("expires_in", 3600L);
|
||||
|
||||
handleLoginSuccess(accessToken, refreshToken, expiresIn);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Failed to parse token response", e);
|
||||
statusTextView.setText("Failed to complete login.");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleLoginSuccess(String accessToken, String refreshToken, long expiresInSeconds) {
|
||||
// Stop all handlers before finishing
|
||||
if (handler != null) {
|
||||
handler.removeCallbacks(pollRunnable);
|
||||
handler.removeCallbacks(timerRunnable);
|
||||
}
|
||||
|
||||
Intent intent = new Intent();
|
||||
intent.putExtra("access_token", accessToken);
|
||||
intent.putExtra("refresh_token", refreshToken);
|
||||
intent.putExtra("expires_in", expiresInSeconds);
|
||||
setResult(RESULT_OK, intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (handler != null) {
|
||||
handler.removeCallbacks(pollRunnable);
|
||||
handler.removeCallbacks(timerRunnable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ import kotlin.Unit;
|
||||
import ml.bmlzootown.hydravion.BuildConfig;
|
||||
import ml.bmlzootown.hydravion.Constants;
|
||||
import ml.bmlzootown.hydravion.R;
|
||||
import ml.bmlzootown.hydravion.authenticate.LoginActivity;
|
||||
import ml.bmlzootown.hydravion.authenticate.AuthManager;
|
||||
import ml.bmlzootown.hydravion.authenticate.LogoutRequestTask;
|
||||
import ml.bmlzootown.hydravion.card.CardPresenter;
|
||||
import ml.bmlzootown.hydravion.client.HydravionClient;
|
||||
@@ -84,8 +84,6 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
private Socket socket;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
public static String sailssid;
|
||||
|
||||
public static List<Subscription> subscriptions = new ArrayList<>();
|
||||
private static NavigableMap<Integer, Video> strms = new TreeMap<>();
|
||||
public static HashMap<String, ArrayList<Video>> videos = new HashMap<>();
|
||||
@@ -99,6 +97,10 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
|
||||
private final Handler liveHandler = new Handler(Looper.getMainLooper());
|
||||
private int liveIndex = -1;
|
||||
private boolean backgroundManagerPrepared = false;
|
||||
private boolean uiInitialized = false;
|
||||
private boolean isLoggedIn = false;
|
||||
private boolean adapterInitialized = false;
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle savedInstanceState) {
|
||||
@@ -128,32 +130,49 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
}
|
||||
|
||||
private void checkLogin() {
|
||||
boolean gotCookies = loadCredentials();
|
||||
if (!gotCookies) {
|
||||
Intent intent = new Intent(getActivity(), LoginActivity.class);
|
||||
AuthManager authManager = AuthManager.Companion.getInstance(requireActivity(), requireActivity().getPreferences(Context.MODE_PRIVATE));
|
||||
authManager.withValidAccessToken(accessToken -> {
|
||||
dLog("LOGIN", "Access token valid (or refreshed successfully)");
|
||||
isLoggedIn = true;
|
||||
initialize();
|
||||
return Unit.INSTANCE;
|
||||
}, () -> {
|
||||
dLog("LOGIN", "No valid access token or refresh token available. Starting login flow.");
|
||||
isLoggedIn = false;
|
||||
Intent intent = new Intent(getActivity(), ml.bmlzootown.hydravion.authenticate.QrLoginActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
|
||||
startActivityForResult(intent, 42);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == 42 && resultCode == 1 && data != null) {
|
||||
ArrayList<String> cookies = data.getStringArrayListExtra("cookies");
|
||||
for (String cookie : cookies) {
|
||||
String[] c = cookie.split("=");
|
||||
if (c[0].equalsIgnoreCase("sails.sid")) {
|
||||
sailssid = c[1];
|
||||
}
|
||||
}
|
||||
dLog("MainFragment", sailssid);
|
||||
if (requestCode == 42 && resultCode == RESULT_OK && data != null) {
|
||||
String accessToken = data.getStringExtra("access_token");
|
||||
String refreshToken = data.getStringExtra("refresh_token");
|
||||
long expiresIn = data.getLongExtra("expires_in", 3600L);
|
||||
|
||||
saveCredentials();
|
||||
initialize();
|
||||
if (accessToken != null && !accessToken.isEmpty()) {
|
||||
long expiresAt = System.currentTimeMillis() + (expiresIn * 1000L);
|
||||
// Use empty string if refreshToken is null to avoid NullPointerException
|
||||
String safeRefreshToken = (refreshToken != null) ? refreshToken : "";
|
||||
requireActivity().getPreferences(Context.MODE_PRIVATE).edit()
|
||||
.putString(Constants.PREF_ACCESS_TOKEN, accessToken)
|
||||
.putString(Constants.PREF_REFRESH_TOKEN, safeRefreshToken)
|
||||
.putLong(Constants.PREF_TOKEN_EXPIRES_AT, expiresAt)
|
||||
.commit();
|
||||
|
||||
// Mark as logged in and initialize
|
||||
isLoggedIn = true;
|
||||
initialize();
|
||||
} else {
|
||||
dLog(TAG, "Login result missing access token; restarting login flow.");
|
||||
// Restart login flow to avoid running without credentials
|
||||
checkLogin();
|
||||
}
|
||||
} else if (requestCode == Constants.REQ_CODE_DETAIL && resultCode == RESULT_OK && data != null) {
|
||||
if (data.getBooleanExtra("REFRESH", false)) {
|
||||
refreshVideoProgress();
|
||||
@@ -164,18 +183,31 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
private void initialize() {
|
||||
refreshSubscriptions();
|
||||
prepareBackgroundManager();
|
||||
setupUIElements();
|
||||
setupEventListeners();
|
||||
|
||||
|
||||
// Only setup UI elements and listeners once, before views are created
|
||||
if (!uiInitialized) {
|
||||
setupUIElements();
|
||||
setupEventListeners();
|
||||
uiInitialized = true;
|
||||
}
|
||||
// TODO: Temporarily disabled - backend doesn't support auth tokens with websockets yet
|
||||
// Setup Socket
|
||||
setupSocket();
|
||||
// setupSocket();
|
||||
}
|
||||
|
||||
private void setupSocket() {
|
||||
socket = socketClient.initialize();
|
||||
socket.on("connect", onSocketConnect);
|
||||
socket.on("disconnect", onSocketDisconnect);
|
||||
socket.on("syncEvent", onSyncEvent);
|
||||
socketClient.initialize(sock -> {
|
||||
if (sock == null) {
|
||||
dLog("SOCKET", "Failed to initialize socket due to auth error");
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
socket = sock;
|
||||
socket.on("connect", onSocketConnect);
|
||||
socket.on("disconnect", onSocketDisconnect);
|
||||
socket.on("syncEvent", onSyncEvent);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
// Socket Event Emitters
|
||||
@@ -202,7 +234,8 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
|
||||
private final Emitter.Listener onSocketDisconnect = args -> {
|
||||
dLog("SOCKET", "Disconnected");
|
||||
setupSocket();
|
||||
// TODO: Temporarily disabled - backend doesn't support auth tokens with websockets yet
|
||||
// setupSocket();
|
||||
};
|
||||
|
||||
private final Emitter.Listener onSyncEvent = args -> {
|
||||
@@ -243,52 +276,87 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
dLog("SOCKET --> SYNCEVENT", event.toString());
|
||||
};
|
||||
|
||||
private boolean loadCredentials() {
|
||||
SharedPreferences prefs = requireActivity().getPreferences(Context.MODE_PRIVATE);
|
||||
sailssid = prefs.getString(Constants.PREF_SAIL_SSID, "default");
|
||||
dLog("SAILS.SID", sailssid);
|
||||
|
||||
if (sailssid.equals("default")) {
|
||||
dLog("LOGIN", "Credentials not found!");
|
||||
return false;
|
||||
} else {
|
||||
dLog("LOGIN", "Credentials found!");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void logout() {
|
||||
// Invalidate cookies via API
|
||||
LogoutRequestTask lrt = new LogoutRequestTask(getContext());
|
||||
String cookies = "sails.sid=" + sailssid + ";";
|
||||
lrt.logout(cookies, new LogoutRequestTask.VolleyCallback() {
|
||||
@Override
|
||||
public void onSuccess(String response) {
|
||||
dLog("LOGOUT", "Success!");
|
||||
}
|
||||
SharedPreferences prefs = requireActivity().getPreferences(Context.MODE_PRIVATE);
|
||||
String accessToken = prefs.getString(Constants.PREF_ACCESS_TOKEN, null);
|
||||
|
||||
@Override
|
||||
public void onError(VolleyError error) {
|
||||
dLog("LOGOUT --> ERROR", error.getMessage());
|
||||
}
|
||||
});
|
||||
// Best-effort token revocation; ignore errors
|
||||
if (accessToken != null && !accessToken.isEmpty()) {
|
||||
LogoutRequestTask lrt = new LogoutRequestTask(getContext());
|
||||
lrt.logout(accessToken, new LogoutRequestTask.VolleyCallback() {
|
||||
@Override
|
||||
public void onSuccess(String response) {
|
||||
dLog("LOGOUT", "Token revoked");
|
||||
}
|
||||
|
||||
// Removed cookies, save dummy cookies, and close client
|
||||
sailssid = "default";
|
||||
saveCredentials();
|
||||
requireActivity().finishAndRemoveTask();
|
||||
}
|
||||
@Override
|
||||
public void onError(VolleyError error) {
|
||||
dLog("LOGOUT", "Revocation failed: " + error.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void saveCredentials() {
|
||||
requireActivity().getPreferences(Context.MODE_PRIVATE).edit()
|
||||
.putString(Constants.PREF_SAIL_SSID, sailssid)
|
||||
.apply();
|
||||
// Clear tokens and in-memory data
|
||||
// Use AuthManager to clear both SharedPreferences and in-memory cache
|
||||
AuthManager authManager = AuthManager.Companion.getInstance(requireActivity(), prefs);
|
||||
authManager.clearTokens();
|
||||
|
||||
// Clear all in-memory data structures
|
||||
subscriptions.clear();
|
||||
videos.clear();
|
||||
strms.clear();
|
||||
videoProgress.clear();
|
||||
|
||||
// Reset state variables
|
||||
subCount = 0;
|
||||
page = 1;
|
||||
rowSelected = 0;
|
||||
colSelected = 0;
|
||||
liveIndex = -1;
|
||||
|
||||
// Remove any pending live handler callbacks to prevent accessing cleared data
|
||||
liveHandler.removeCallbacksAndMessages(null);
|
||||
|
||||
// Disconnect socket if connected
|
||||
if (socket != null && socket.connected()) {
|
||||
socket.disconnect();
|
||||
socket.off();
|
||||
socket = null;
|
||||
}
|
||||
|
||||
// Clear the adapter to prevent stale data from being displayed
|
||||
if (getAdapter() != null) {
|
||||
setAdapter(null);
|
||||
}
|
||||
|
||||
// Reset adapter initialization flag
|
||||
adapterInitialized = false;
|
||||
|
||||
// Reset UI initialization flag to allow proper setup on next login
|
||||
uiInitialized = false;
|
||||
|
||||
// Mark as logged out to prevent callbacks from updating UI
|
||||
isLoggedIn = false;
|
||||
|
||||
// Restart the QR login flow instead of closing the app
|
||||
checkLogin();
|
||||
}
|
||||
|
||||
private void gotLiveInfo(Subscription sub, Delivery live) {
|
||||
// Guard against processing live info if we're logged out
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring live info update - user is logged out");
|
||||
return;
|
||||
}
|
||||
|
||||
String l = live.getGroups().get(0).getOrigins().get(0).getUrl() + live.getGroups().get(0).getVariants().get(0).getUrl();
|
||||
sub.setStreamUrl(l);
|
||||
client.checkLive(l, (status) -> {
|
||||
// Double-check we're still logged in when callback executes
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring live status callback - user logged out during request");
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
sub.setStreaming(status == 200);
|
||||
dLog("LIVE STATUS", String.valueOf(status));
|
||||
return Unit.INSTANCE;
|
||||
@@ -301,12 +369,14 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
if (subscriptions == null) {
|
||||
new AlertDialog.Builder(getContext())
|
||||
.setTitle("Session Expired")
|
||||
.setMessage("Re-open Hydravion to login again!")
|
||||
.setPositiveButton("OK",
|
||||
.setMessage("Your Floatplane session has expired. Please relink your account.")
|
||||
.setPositiveButton("Relink",
|
||||
(dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
logout();
|
||||
})
|
||||
.setNegativeButton("Cancel",
|
||||
(dialog, which) -> dialog.dismiss())
|
||||
.create()
|
||||
.show();
|
||||
} else {
|
||||
@@ -330,6 +400,12 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
}
|
||||
|
||||
private void gotSubscriptions(Subscription[] subs) {
|
||||
// Guard against processing subscriptions if we're logged out
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring subscription update - user is logged out");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Subscription> trimmed = new ArrayList<>();
|
||||
for (Subscription sub : subs) {
|
||||
if (trimmed.size() > 0) {
|
||||
@@ -368,23 +444,55 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
}
|
||||
|
||||
private void gotVideos(String creatorGUID, Video[] vids) {
|
||||
// Guard against processing videos if we're logged out
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring video update - user is logged out");
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isPagination = adapterInitialized && videos.get(creatorGUID) != null && videos.get(creatorGUID).size() > 0;
|
||||
int previousSize = (videos.get(creatorGUID) != null) ? videos.get(creatorGUID).size() : 0;
|
||||
|
||||
if (videos.get(creatorGUID) != null && videos.get(creatorGUID).size() > 0) {
|
||||
videos.get(creatorGUID).addAll(Arrays.asList(vids));
|
||||
} else {
|
||||
videos.put(creatorGUID, new ArrayList<>(Arrays.asList(vids)));
|
||||
}
|
||||
|
||||
if (subCount > 1) {
|
||||
subCount--;
|
||||
if (isPagination) {
|
||||
// For pagination, append videos immediately for this creator without waiting for others
|
||||
appendVideosToRows(creatorGUID, previousSize);
|
||||
// Still track subCount for coordination, but don't block on it
|
||||
if (subCount > 1) {
|
||||
subCount--;
|
||||
} else {
|
||||
subCount = subscriptions.size();
|
||||
}
|
||||
} else {
|
||||
refreshVideoProgress();
|
||||
subCount = subscriptions.size();
|
||||
setSelectedPosition(rowSelected, false, new ListRowPresenter.SelectItemViewHolderTask(colSelected));
|
||||
// Initial load - wait for all subscriptions to finish, then do full refresh
|
||||
if (subCount > 1) {
|
||||
subCount--;
|
||||
} else {
|
||||
refreshVideoProgress();
|
||||
subCount = subscriptions.size();
|
||||
setSelectedPosition(rowSelected, false, new ListRowPresenter.SelectItemViewHolderTask(colSelected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshVideoProgress() {
|
||||
// Guard against refreshing progress if we're logged out
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring video progress refresh - user is logged out");
|
||||
return;
|
||||
}
|
||||
|
||||
client.getVideoProgress(MapExtensionKt.getBlogPostIdsFromCreatorMap(videos), progress -> {
|
||||
// Double-check we're still logged in when callback executes
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring video progress callback - user logged out during request");
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
videoProgress = progress;
|
||||
refreshRows();
|
||||
return Unit.INSTANCE;
|
||||
@@ -392,6 +500,12 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
}
|
||||
|
||||
private void refreshRows() {
|
||||
// Guard against refreshing rows if we're logged out
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring row refresh - user is logged out");
|
||||
return;
|
||||
}
|
||||
|
||||
List<Subscription> subs = subscriptions;
|
||||
ArrayObjectAdapter rowsAdapter = new ArrayObjectAdapter(new ListRowPresenter());
|
||||
CardPresenter cardPresenter = new CardPresenter(videoProgress);
|
||||
@@ -468,6 +582,7 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
rowsAdapter.add(new ListRow(gridHeader, gridRowAdapter));
|
||||
|
||||
setAdapter(rowsAdapter);
|
||||
adapterInitialized = true;
|
||||
}
|
||||
|
||||
private void addLiveToRow(Integer row, Video stream, List<Subscription> subs) {
|
||||
@@ -516,6 +631,63 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
liveHandler.post(runnable);
|
||||
}
|
||||
|
||||
private void appendVideosToRows(String creatorGUID, int previousSize) {
|
||||
// Guard against appending if we're logged out
|
||||
if (!isLoggedIn) {
|
||||
dLog(TAG, "Ignoring video append - user is logged out");
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayObjectAdapter rowsAdapter = (ArrayObjectAdapter) getAdapter();
|
||||
if (rowsAdapter == null) {
|
||||
dLog(TAG, "Adapter not initialized, falling back to full refresh");
|
||||
refreshVideoProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
int rowIndex = getRow(creatorGUID, subscriptions);
|
||||
if (rowIndex == -1 || rowIndex >= rowsAdapter.size()) {
|
||||
dLog(TAG, "Invalid row index for creator: " + creatorGUID);
|
||||
return;
|
||||
}
|
||||
|
||||
ListRow listRow = (ListRow) rowsAdapter.get(rowIndex);
|
||||
if (listRow == null) {
|
||||
dLog(TAG, "ListRow is null for row index: " + rowIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayObjectAdapter listRowAdapter = (ArrayObjectAdapter) listRow.getAdapter();
|
||||
if (listRowAdapter == null) {
|
||||
dLog(TAG, "ListRow adapter is null for row index: " + rowIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Video> vids = videos.get(creatorGUID);
|
||||
if (vids == null || vids.size() <= previousSize) {
|
||||
dLog(TAG, "No new videos to append for creator: " + creatorGUID);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get new videos that were added
|
||||
List<Video> newVideos = vids.subList(previousSize, vids.size());
|
||||
|
||||
// Store the insertion position before adding items
|
||||
int insertPosition = listRowAdapter.size();
|
||||
|
||||
// Add new videos to the adapter (add() automatically notifies, but we'll be explicit for the range)
|
||||
for (Video video : newVideos) {
|
||||
listRowAdapter.add(video);
|
||||
}
|
||||
|
||||
// Notify adapter of the new items for smooth insertion without full refresh
|
||||
// Using notifyArrayItemRangeChanged to match existing code pattern
|
||||
listRowAdapter.notifyArrayItemRangeChanged(insertPosition, newVideos.size());
|
||||
|
||||
// Selection position should be automatically preserved since we're not calling setAdapter()
|
||||
dLog(TAG, "Appended " + newVideos.size() + " videos to row " + rowIndex + " starting at position " + insertPosition);
|
||||
}
|
||||
|
||||
private void addToRow(Video video, List<Subscription> subs) {
|
||||
dLog("addToRow", video.getGuid());
|
||||
for (int i = 0; i < subs.size(); i++) {
|
||||
@@ -565,11 +737,24 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
}
|
||||
|
||||
private void prepareBackgroundManager() {
|
||||
BackgroundManager mBackgroundManager = BackgroundManager.getInstance(requireActivity());
|
||||
mBackgroundManager.attach(requireActivity().getWindow());
|
||||
|
||||
DisplayMetrics mMetrics = new DisplayMetrics();
|
||||
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
|
||||
// BackgroundManager is a singleton per activity, so we need to avoid attaching multiple times
|
||||
if (backgroundManagerPrepared) {
|
||||
dLog(TAG, "BackgroundManager already prepared, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
BackgroundManager mBackgroundManager = BackgroundManager.getInstance(requireActivity());
|
||||
mBackgroundManager.attach(requireActivity().getWindow());
|
||||
backgroundManagerPrepared = true;
|
||||
|
||||
DisplayMetrics mMetrics = new DisplayMetrics();
|
||||
requireActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
|
||||
} catch (IllegalStateException e) {
|
||||
// BackgroundManager is already attached, which is fine
|
||||
dLog(TAG, "BackgroundManager already attached: " + e.getMessage());
|
||||
backgroundManagerPrepared = true;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
@@ -650,6 +835,7 @@ public class MainFragment extends BrowseSupportFragment {
|
||||
switch (action) {
|
||||
case REFRESH:
|
||||
videos.clear();
|
||||
adapterInitialized = false; // Reset flag on manual refresh
|
||||
refreshSubscriptions(); // Refresh will get subs and videos again, then refresh row UI
|
||||
break;
|
||||
case LOGOUT:
|
||||
|
||||
@@ -129,7 +129,7 @@ class CardPresenter(private val videoProgress: List<VideoProgress>) : Presenter(
|
||||
.load(
|
||||
GlideUrl(
|
||||
thumbnail, LazyHeaders.Builder()
|
||||
.addHeader("User-Agent", "Hydravion (AndroidTV $version), CFNetwork")
|
||||
.addHeader("User-Agent", "Hydravion (AndroidTV $version)")
|
||||
.build()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.google.gson.Gson
|
||||
import ml.bmlzootown.hydravion.BuildConfig
|
||||
import ml.bmlzootown.hydravion.Constants
|
||||
import ml.bmlzootown.hydravion.browse.MainFragment
|
||||
import ml.bmlzootown.hydravion.authenticate.AuthManager
|
||||
import ml.bmlzootown.hydravion.creator.Creator
|
||||
import ml.bmlzootown.hydravion.creator.FloatplaneLiveStream
|
||||
import ml.bmlzootown.hydravion.github.Release
|
||||
@@ -23,16 +24,11 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
private val creatorIds: MutableMap<String, String> = hashMapOf()
|
||||
private val creatorCache: MutableMap<String, Creator> = hashMapOf()
|
||||
private val requestTask: RequestTask = RequestTask(context)
|
||||
|
||||
/**
|
||||
* Convenience fun to get cookies string
|
||||
* @return Cookies string
|
||||
*/
|
||||
private fun getCookiesString(): String =
|
||||
"${Constants.PREF_SAIL_SSID}=${mainPrefs.getString(Constants.PREF_SAIL_SSID, "")}"
|
||||
private val authManager: AuthManager = AuthManager.getInstance(context, mainPrefs)
|
||||
|
||||
fun getSubs(callback: (Array<Subscription>?) -> Unit) {
|
||||
requestTask.sendRequest(URI_SUBSCRIPTIONS, getCookiesString(), object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(URI_SUBSCRIPTIONS, token, object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onResponseCode(response: Int) {
|
||||
//Ignore
|
||||
@@ -70,23 +66,26 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = callback(null)
|
||||
})
|
||||
}, {
|
||||
callback(null)
|
||||
})
|
||||
}
|
||||
|
||||
fun getCreatorInfo(creatorGUID: String, callback: (FloatplaneLiveStream) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_CREATOR_INFO?creatorGUID=$creatorGUID",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_CREATOR_INFO?id=$creatorGUID",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
override fun onSuccess(response: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
MainFragment.dLog(TAG,"getCreatorInfo: $response")
|
||||
}
|
||||
|
||||
try {
|
||||
JSONArray(response).getString(0)?.let {
|
||||
Gson().fromJson(it, Creator::class.java).let { creator ->
|
||||
creator.lastLiveStream?.let { it1 -> callback.invoke(it1) }
|
||||
}
|
||||
// v3 API returns a single object, not an array
|
||||
Gson().fromJson(response, Creator::class.java).let { creator ->
|
||||
creator.lastLiveStream?.let { it1 -> callback.invoke(it1) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -99,14 +98,18 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op on failure
|
||||
})
|
||||
}
|
||||
|
||||
fun getVideos(creatorGUID: String, page: Int, callback: (Array<Video>) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_VIDEOS?id=$creatorGUID&fetchAfter=${(page - 1) * 20}",
|
||||
getCookiesString(),
|
||||
creatorGUID,
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_VIDEOS?id=$creatorGUID&fetchAfter=${(page - 1) * 20}",
|
||||
token,
|
||||
creatorGUID,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onResponseCode(response: Int) = Unit
|
||||
|
||||
@@ -122,14 +125,18 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
callback(emptyArray())
|
||||
})
|
||||
}
|
||||
|
||||
fun getVideo(video: Video, res: String, callback: (Video) -> Unit) {
|
||||
//val y = Util.getCurrentDisplayModeSize(context).y;
|
||||
requestTask.sendRequest(
|
||||
"$URI_DELIVERY?scenario=onDemand&entityId=${video.getVideoId()}",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_DELIVERY?scenario=onDemand&entityId=${video.getVideoId()}",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
@@ -174,14 +181,18 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fun getVideoObject(id: String, callback: (Video) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_POST?id=$id",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_POST?id=$id",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
override fun onSuccess(response: String) {
|
||||
try {
|
||||
callback(Gson().fromJson(response, Video::class.java))
|
||||
@@ -196,13 +207,17 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
fun getVideoInfo(videoID: String, callback: (VideoInfo) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_VIDEO_INFO?id=$videoID",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_VIDEO_INFO?id=$videoID",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
@@ -219,13 +234,17 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
fun getLive(sub: Subscription, callback: (Delivery) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_CREATOR?id=${sub.creator}",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_CREATOR?id=${sub.creator}",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
val c: Creator = Gson().fromJson(response, Creator::class.java)
|
||||
@@ -243,13 +262,17 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
fun getLive(livestreamID: String, callback: (Delivery) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_DELIVERY?scenario=live&entityId=$livestreamID",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_DELIVERY?scenario=live&entityId=$livestreamID",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
callback(Gson().fromJson(response, Delivery::class.java))
|
||||
@@ -261,6 +284,9 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
/*fun getLive(creatorGUID: String, callback: (Live) -> Unit) {
|
||||
@@ -315,18 +341,18 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
return
|
||||
}
|
||||
|
||||
requestTask.sendRequest(
|
||||
"$URI_CREATOR_INFO?creatorGUID=$creatorGUID",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_CREATOR_INFO?id=$creatorGUID",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
try {
|
||||
JSONArray(response).getString(0)?.let {
|
||||
Gson().fromJson(it, Creator::class.java).let { creator ->
|
||||
creatorCache[creatorGUID] = creator
|
||||
callback?.invoke(creator)
|
||||
}
|
||||
// v3 API returns a single object, not an array
|
||||
Gson().fromJson(response, Creator::class.java).let { creator ->
|
||||
creatorCache[creatorGUID] = creator
|
||||
callback?.invoke(creator)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -339,13 +365,17 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
fun getPost(postId: String, callback: (Post) -> Unit) {
|
||||
requestTask.sendRequest(
|
||||
"$URI_POST?id=$postId",
|
||||
getCookiesString(),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendRequest(
|
||||
"$URI_POST?id=$postId",
|
||||
token,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
@@ -361,7 +391,10 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
override fun onSuccessCreator(response: String, creatorGUID: String) = Unit
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
});
|
||||
})
|
||||
}, {
|
||||
// no-op
|
||||
})
|
||||
}
|
||||
|
||||
fun getLatest(callback: (String) -> Unit) {
|
||||
@@ -385,11 +418,12 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
}
|
||||
|
||||
fun toggleLikePost(postId: String, callback: (Boolean) -> Unit) {
|
||||
requestTask.sendData(
|
||||
URI_LIKE,
|
||||
getCookiesString(),
|
||||
mapOf("id" to postId, "contentType" to "blogPost"),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendData(
|
||||
URI_LIKE,
|
||||
token,
|
||||
mapOf("id" to postId, "contentType" to "blogPost"),
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
callback(response.contains("like"))
|
||||
@@ -401,14 +435,18 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
callback(false)
|
||||
})
|
||||
}
|
||||
|
||||
fun toggleDislikePost(postId: String, callback: (Boolean) -> Unit) {
|
||||
requestTask.sendData(
|
||||
URI_DISLIKE,
|
||||
getCookiesString(),
|
||||
mapOf("id" to postId, "contentType" to "blogPost"),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendData(
|
||||
URI_DISLIKE,
|
||||
token,
|
||||
mapOf("id" to postId, "contentType" to "blogPost"),
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
callback(response.contains("dislike"))
|
||||
@@ -420,6 +458,9 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
})
|
||||
}, {
|
||||
callback(false)
|
||||
})
|
||||
}
|
||||
|
||||
fun getVideoProgress(blogPostIds: List<String>, callback: (List<VideoProgress>) -> Unit) {
|
||||
@@ -429,11 +470,12 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
json.put("contentType", "blogPost")
|
||||
json
|
||||
}.toString()
|
||||
requestTask.sendDataWithBody(
|
||||
URI_GET_PROGRESS,
|
||||
getCookiesString(),
|
||||
body,
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendDataWithBody(
|
||||
URI_GET_PROGRESS,
|
||||
token,
|
||||
body,
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) {
|
||||
try {
|
||||
@@ -452,16 +494,19 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
override fun onError(error: VolleyError) {
|
||||
callback(ArrayList())
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}, {
|
||||
callback(ArrayList())
|
||||
})
|
||||
}
|
||||
|
||||
fun setVideoProgress(videoId: String, progressInPercent: Int) {
|
||||
requestTask.sendData(
|
||||
URI_UPDATE_PROGRESS,
|
||||
getCookiesString(),
|
||||
mapOf("id" to videoId, "contentType" to "video", "progress" to progressInPercent.toString()),
|
||||
object : RequestTask.VolleyCallback {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
requestTask.sendData(
|
||||
URI_UPDATE_PROGRESS,
|
||||
token,
|
||||
mapOf("id" to videoId, "contentType" to "video", "progress" to progressInPercent.toString()),
|
||||
object : RequestTask.VolleyCallback {
|
||||
|
||||
override fun onSuccess(response: String) = Unit
|
||||
|
||||
@@ -470,8 +515,10 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
override fun onSuccessCreator(response: String, creatorGUID: String) = Unit
|
||||
|
||||
override fun onError(error: VolleyError) = Unit
|
||||
}
|
||||
)
|
||||
})
|
||||
}, {
|
||||
// ignore
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -479,9 +526,9 @@ class HydravionClient private constructor(private val context: Context, private
|
||||
private const val TAG = "HydravionClient"
|
||||
private const val SITE = "https://www.floatplane.com"
|
||||
|
||||
// TODO Update to v3 API
|
||||
private const val URI_SUBSCRIPTIONS = "$SITE/api/user/subscriptions"
|
||||
private const val URI_CREATOR_INFO = "$SITE/api/creator/info"
|
||||
// Updated to v3 API
|
||||
private const val URI_SUBSCRIPTIONS = "$SITE/api/v3/user/subscriptions"
|
||||
private const val URI_CREATOR_INFO = "$SITE/api/v3/creator/info"
|
||||
|
||||
// Already updated!
|
||||
private const val URI_DELIVERY = "$SITE/api/v3/delivery/info"
|
||||
|
||||
@@ -34,7 +34,7 @@ class RequestTask(context: Context) {
|
||||
volleyQueue.add(stringRequest)
|
||||
}
|
||||
|
||||
fun sendRequest(uri: String?, cookies: String, callback: VolleyCallback) {
|
||||
fun sendRequest(uri: String?, accessToken: String, callback: VolleyCallback) {
|
||||
val stringRequest: StringRequest = object : StringRequest(Method.GET, uri,
|
||||
Response.Listener { response: String? ->
|
||||
callback.onSuccess(response ?: "")
|
||||
@@ -42,18 +42,17 @@ class RequestTask(context: Context) {
|
||||
error.printStackTrace()
|
||||
callback.onError(error)
|
||||
}) {
|
||||
override fun getHeaders(): Map<String, String> {
|
||||
val params: MutableMap<String, String> = HashMap()
|
||||
params["Cookie"] = cookies
|
||||
params["Accept"] = "application/json"
|
||||
params["User-Agent"] = "Hydravion (AndroidTV $version), CFNetwork"
|
||||
return params
|
||||
}
|
||||
override fun getHeaders(): Map<String, String> =
|
||||
mapOf(
|
||||
"Authorization" to "Bearer $accessToken",
|
||||
"Accept" to "application/json",
|
||||
"User-Agent" to "Hydravion (AndroidTV $version)"
|
||||
)
|
||||
}
|
||||
volleyQueue.add(stringRequest)
|
||||
}
|
||||
|
||||
fun sendData(uri: String?, cookies: String, params: Map<String, String>?, callback: VolleyCallback) {
|
||||
fun sendData(uri: String?, accessToken: String, params: Map<String, String>?, callback: VolleyCallback) {
|
||||
val stringRequest: StringRequest = object : StringRequest(
|
||||
Method.POST,
|
||||
uri,
|
||||
@@ -70,15 +69,15 @@ class RequestTask(context: Context) {
|
||||
@Throws(AuthFailureError::class)
|
||||
override fun getHeaders(): Map<String, String> =
|
||||
mapOf(
|
||||
"Cookie" to cookies,
|
||||
"Authorization" to "Bearer $accessToken",
|
||||
"Accept" to ACCEPT_JSON,
|
||||
"User-Agent" to "Hydravion (AndroidTV $version), CFNetwork"
|
||||
"User-Agent" to "Hydravion (AndroidTV $version)"
|
||||
)
|
||||
}
|
||||
volleyQueue.add(stringRequest)
|
||||
}
|
||||
|
||||
fun sendDataWithBody(uri: String?, cookies: String, body: String, callback: VolleyCallback) {
|
||||
fun sendDataWithBody(uri: String?, accessToken: String, body: String, callback: VolleyCallback) {
|
||||
val jsonRequest: JsonRequest<String> = object : JsonRequest<String>(
|
||||
Method.POST,
|
||||
uri,
|
||||
@@ -93,9 +92,9 @@ class RequestTask(context: Context) {
|
||||
@Throws(AuthFailureError::class)
|
||||
override fun getHeaders(): Map<String, String> =
|
||||
mapOf(
|
||||
"Cookie" to cookies,
|
||||
"Authorization" to "Bearer $accessToken",
|
||||
"Accept" to ACCEPT_JSON,
|
||||
"User-Agent" to "Hydravion (AndroidTV $version), CFNetwork",
|
||||
"User-Agent" to "Hydravion (AndroidTV $version)",
|
||||
"Content-Type" to "application/json"
|
||||
)
|
||||
|
||||
@@ -107,7 +106,7 @@ class RequestTask(context: Context) {
|
||||
volleyQueue.add(jsonRequest)
|
||||
}
|
||||
|
||||
fun sendRequest(uri: String?, cookies: String, creatorGUID: String?, callback: VolleyCallback) {
|
||||
fun sendRequest(uri: String?, accessToken: String, creatorGUID: String?, callback: VolleyCallback) {
|
||||
val stringRequest: StringRequest = object : StringRequest(
|
||||
Method.GET,
|
||||
uri,
|
||||
@@ -120,9 +119,9 @@ class RequestTask(context: Context) {
|
||||
|
||||
override fun getHeaders(): Map<String, String> =
|
||||
mapOf(
|
||||
"Cookie" to cookies,
|
||||
"Authorization" to "Bearer $accessToken",
|
||||
"Accept" to ACCEPT_JSON,
|
||||
"User-Agent" to "Hydravion (AndroidTV $version), CFNetwork"
|
||||
"User-Agent" to "Hydravion (AndroidTV $version)"
|
||||
)
|
||||
}
|
||||
volleyQueue.add(stringRequest)
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.socket.client.Socket
|
||||
import io.socket.engineio.client.Transport
|
||||
import io.socket.engineio.client.transports.WebSocket
|
||||
import ml.bmlzootown.hydravion.Constants
|
||||
import ml.bmlzootown.hydravion.authenticate.AuthManager
|
||||
import ml.bmlzootown.hydravion.browse.MainFragment
|
||||
import ml.bmlzootown.hydravion.post.Post
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -22,58 +23,53 @@ import java.util.*
|
||||
|
||||
class SocketClient private constructor(private val context: Context, private val mainPrefs: SharedPreferences) {
|
||||
|
||||
/**
|
||||
* Convenience fun to get cookies string
|
||||
* @return Cookies string
|
||||
*/
|
||||
val version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME
|
||||
private val authManager: AuthManager = AuthManager.getInstance(context, mainPrefs)
|
||||
|
||||
private fun getCookiesString(): String =
|
||||
"${Constants.PREF_SAIL_SSID}=${mainPrefs.getString(Constants.PREF_SAIL_SSID, "")}"
|
||||
// Initialize the WebSocket connection, ensuring we use a fresh access token.
|
||||
fun initialize(onReady: (Socket?) -> Unit) {
|
||||
authManager.withValidAccessToken({ token ->
|
||||
val okHttpClient = OkHttpClient.Builder().build()
|
||||
IO.setDefaultOkHttpWebSocketFactory(okHttpClient)
|
||||
IO.setDefaultOkHttpCallFactory(okHttpClient)
|
||||
|
||||
fun initialize(): Socket {
|
||||
val heads = mutableMapOf<String, List<String>>()
|
||||
heads["Origin"] = listOf("https://www.floatplane.com")
|
||||
heads["Cookie"] = listOf(getCookiesString())
|
||||
heads["User-Agent"] = listOf("Hydravion (AndroidTV $version), CFNetwork")
|
||||
val uri = URI.create(SOCKET_URI)
|
||||
|
||||
val okHttpClient = OkHttpClient.Builder().build()
|
||||
IO.setDefaultOkHttpWebSocketFactory(okHttpClient)
|
||||
IO.setDefaultOkHttpCallFactory(okHttpClient)
|
||||
val opts = IO.Options()
|
||||
opts.query = "__sails_io_sdk_version=1.2.1&__sails_io_sdk_platform=browser&__sails_io_sdk_language=javascript"
|
||||
opts.transports = arrayOf(WebSocket.NAME)
|
||||
opts.forceNew = true
|
||||
opts.callFactory = okHttpClient
|
||||
opts.webSocketFactory = okHttpClient
|
||||
|
||||
val uri = URI.create(SOCKET_URI)
|
||||
val socket = IO.socket(uri, opts)
|
||||
|
||||
val opts = IO.Options()
|
||||
opts.query = "__sails_io_sdk_version=1.2.1&__sails_io_sdk_platform=browser&__sails_io_sdk_language=javascript"
|
||||
opts.transports = arrayOf(WebSocket.NAME)
|
||||
opts.forceNew = true
|
||||
opts.callFactory = okHttpClient;
|
||||
opts.webSocketFactory = okHttpClient;
|
||||
|
||||
val socket = IO.socket(uri, opts)
|
||||
|
||||
// Modify *initial* request headers
|
||||
socket.io().on(Manager.EVENT_TRANSPORT) { args ->
|
||||
val transport: Transport = args[0] as Transport
|
||||
transport.on(Transport.EVENT_REQUEST_HEADERS) {
|
||||
// Request Headers
|
||||
val headers = it[0] as MutableMap<String, List<String>>
|
||||
// Modify Request Headers
|
||||
headers["Origin"] = listOf("https://www.floatplane.com")
|
||||
headers["Cookie"] = listOf(getCookiesString())
|
||||
headers["User-Agent"] = listOf("Hydravion (AndroidTV $version), CFNetwork")
|
||||
MainFragment.dLog("$TAG --> MODIFYING HEADERS", headers.toString())
|
||||
// Modify *initial* request headers
|
||||
socket.io().on(Manager.EVENT_TRANSPORT) { args ->
|
||||
val transport: Transport = args[0] as Transport
|
||||
transport.on(Transport.EVENT_REQUEST_HEADERS) {
|
||||
// Request Headers
|
||||
val headers = it[0] as MutableMap<String, List<String>>
|
||||
// Always use the latest access token
|
||||
val currentToken = authManager.getAccessToken()
|
||||
headers["Origin"] = listOf("https://www.floatplane.com")
|
||||
headers["Authorization"] = listOf("Bearer $currentToken")
|
||||
headers["User-Agent"] = listOf("Hydravion (AndroidTV $version)")
|
||||
MainFragment.dLog("$TAG --> MODIFYING HEADERS", headers.toString())
|
||||
}
|
||||
transport.on(Transport.EVENT_RESPONSE_HEADERS){
|
||||
// Response Headers
|
||||
val headers = it[0] as Map<String, List<String>>
|
||||
MainFragment.dLog("$TAG --> RESPONSE HEADERS", headers.toString())
|
||||
}
|
||||
}
|
||||
transport.on(Transport.EVENT_RESPONSE_HEADERS){
|
||||
// Response Headers
|
||||
val headers = it[0] as Map<String, List<String>>
|
||||
MainFragment.dLog("$TAG --> RESPONSE HEADERS", headers.toString())
|
||||
}
|
||||
}
|
||||
|
||||
socket.connect()
|
||||
|
||||
return socket
|
||||
socket.connect()
|
||||
onReady(socket)
|
||||
}, {
|
||||
MainFragment.dLog(TAG, "Failed to obtain access token for socket")
|
||||
onReady(null)
|
||||
})
|
||||
}
|
||||
|
||||
// Methods to parse UserSync and SyncEvents
|
||||
|
||||
@@ -75,7 +75,7 @@ public class VideoDetailsFragment extends DetailsSupportFragment {
|
||||
private DetailsSupportFragmentBackgroundController mDetailsBackground;
|
||||
|
||||
private static final String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
|
||||
private static final String userAgent = String.format("Hydravion %s (AndroidTV), CFNetwork", version);
|
||||
private static final String userAgent = String.format("Hydravion %s (AndroidTV)", version);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import java.util.HashMap;
|
||||
|
||||
import kotlin.Unit;
|
||||
import ml.bmlzootown.hydravion.R;
|
||||
import ml.bmlzootown.hydravion.authenticate.AuthManager;
|
||||
import ml.bmlzootown.hydravion.browse.MainFragment;
|
||||
import ml.bmlzootown.hydravion.client.HydravionClient;
|
||||
import ml.bmlzootown.hydravion.detail.DetailsActivity;
|
||||
@@ -57,6 +58,8 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
private int currentWindow = 0;
|
||||
private long playbackPosition = 0;
|
||||
private boolean resumed = false;
|
||||
private boolean playerInitialized = false;
|
||||
private boolean initializationInProgress = false;
|
||||
|
||||
private String url = "";
|
||||
private Video video;
|
||||
@@ -101,9 +104,10 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
super.onStart();
|
||||
|
||||
if (Util.SDK_INT > 23) {
|
||||
initializePlayer();
|
||||
mediaController.setPlayer(player);
|
||||
mediaSession.setActive(true);
|
||||
// Only initialize if not already initialized or in progress
|
||||
if (!playerInitialized && !initializationInProgress && player == null) {
|
||||
initializePlayer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +116,10 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
super.onResume();
|
||||
|
||||
if (Util.SDK_INT <= 23 || player == null) {
|
||||
initializePlayer();
|
||||
mediaController.setPlayer(player);
|
||||
mediaSession.setActive(true);
|
||||
// Only initialize if not already initialized or in progress
|
||||
if (!playerInitialized && !initializationInProgress) {
|
||||
initializePlayer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +128,15 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
super.onPause();
|
||||
|
||||
if (Util.SDK_INT <= 23) {
|
||||
mediaController.setPlayer(null);
|
||||
// Cancel any in-progress initialization
|
||||
if (initializationInProgress) {
|
||||
initializationInProgress = false;
|
||||
}
|
||||
|
||||
if (playerInitialized) {
|
||||
mediaController.setPlayer(null);
|
||||
playerInitialized = false;
|
||||
}
|
||||
mediaSession.setActive(false);
|
||||
saveVideoPosition();
|
||||
releasePlayer();
|
||||
@@ -135,7 +148,15 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
super.onStop();
|
||||
|
||||
if (Util.SDK_INT > 23) {
|
||||
mediaController.setPlayer(null);
|
||||
// Cancel any in-progress initialization
|
||||
if (initializationInProgress) {
|
||||
initializationInProgress = false;
|
||||
}
|
||||
|
||||
if (playerInitialized) {
|
||||
mediaController.setPlayer(null);
|
||||
playerInitialized = false;
|
||||
}
|
||||
mediaSession.setActive(false);
|
||||
saveVideoPosition();
|
||||
releasePlayer();
|
||||
@@ -232,51 +253,89 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
}
|
||||
|
||||
private void initializePlayer() {
|
||||
player = new ExoPlayer.Builder(this).build();
|
||||
player.setPlayWhenReady(playWhenReady);
|
||||
player.seekTo(currentWindow, playbackPosition);
|
||||
playerView.setPlayer(player);
|
||||
DefaultHttpDataSource.Factory dataSourceFactory = new DefaultHttpDataSource.Factory();
|
||||
HashMap<String, String> cookieMap = new HashMap<>();
|
||||
cookieMap.put("Cookie", "sails.sid=" + MainFragment.sailssid + ";");
|
||||
dataSourceFactory.setDefaultRequestProperties(cookieMap);
|
||||
int flags = DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES | DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS;
|
||||
DefaultHlsExtractorFactory extractorFactory = new DefaultHlsExtractorFactory(flags, true);
|
||||
MediaItem mi = MediaItem.fromUri(url);
|
||||
HlsMediaSource hlsMediaSource = new HlsMediaSource.Factory(dataSourceFactory).setExtractorFactory(extractorFactory).createMediaSource(mi);
|
||||
player.setMediaSource(hlsMediaSource);
|
||||
|
||||
player.prepare();
|
||||
|
||||
player.addListener(new Player.Listener() {
|
||||
|
||||
@Override
|
||||
public void onPlayerError(@NonNull PlaybackException error) {
|
||||
if (video != null) {
|
||||
releasePlayer();
|
||||
Toast.makeText(PlaybackActivity.this, "Video could not be played!", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
MainFragment.dError("EXOPLAYER", error.getLocalizedMessage());
|
||||
// Mark initialization as in progress
|
||||
initializationInProgress = true;
|
||||
|
||||
AuthManager authManager = AuthManager.Companion.getInstance(this, getPreferences(Context.MODE_PRIVATE));
|
||||
authManager.withValidAccessToken(accessToken -> {
|
||||
// Check if initialization was cancelled or activity is no longer valid
|
||||
if (!initializationInProgress || isFinishing() || isDestroyed()) {
|
||||
initializationInProgress = false;
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
player = new ExoPlayer.Builder(this).build();
|
||||
player.setPlayWhenReady(playWhenReady);
|
||||
player.seekTo(currentWindow, playbackPosition);
|
||||
playerView.setPlayer(player);
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(int state) {
|
||||
MainFragment.dLog("STATE", state + "");
|
||||
switch (state) {
|
||||
case Player.STATE_READY:
|
||||
if (getIntent().getBooleanExtra(DetailsActivity.Resume, false) && !resumed) {
|
||||
player.seekTo(video.getVideoInfo().getProgress() * 1000);
|
||||
resumed = true;
|
||||
}
|
||||
break;
|
||||
case Player.STATE_ENDED:
|
||||
saveVideoPosition();
|
||||
DefaultHttpDataSource.Factory dataSourceFactory = new DefaultHttpDataSource.Factory();
|
||||
String version = ml.bmlzootown.hydravion.BuildConfig.VERSION_NAME;
|
||||
HashMap<String, String> headers = new HashMap<>();
|
||||
headers.put("Authorization", "Bearer " + accessToken);
|
||||
headers.put("User-Agent", "Hydravion (AndroidTV " + version + ")");
|
||||
dataSourceFactory.setDefaultRequestProperties(headers);
|
||||
|
||||
int flags = DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES | DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS;
|
||||
DefaultHlsExtractorFactory extractorFactory = new DefaultHlsExtractorFactory(flags, true);
|
||||
MediaItem mi = MediaItem.fromUri(url);
|
||||
HlsMediaSource hlsMediaSource = new HlsMediaSource.Factory(dataSourceFactory).setExtractorFactory(extractorFactory).createMediaSource(mi);
|
||||
player.setMediaSource(hlsMediaSource);
|
||||
|
||||
player.prepare();
|
||||
|
||||
// Set up player listener
|
||||
player.addListener(new Player.Listener() {
|
||||
|
||||
@Override
|
||||
public void onPlayerError(@NonNull PlaybackException error) {
|
||||
if (video != null) {
|
||||
releasePlayer();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
Toast.makeText(PlaybackActivity.this, "Video could not be played!", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
MainFragment.dError("EXOPLAYER", error.getLocalizedMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(int state) {
|
||||
MainFragment.dLog("STATE", state + "");
|
||||
switch (state) {
|
||||
case Player.STATE_READY:
|
||||
if (getIntent().getBooleanExtra(DetailsActivity.Resume, false) && !resumed) {
|
||||
player.seekTo(video.getVideoInfo().getProgress() * 1000);
|
||||
resumed = true;
|
||||
}
|
||||
break;
|
||||
case Player.STATE_ENDED:
|
||||
saveVideoPosition();
|
||||
releasePlayer();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only set up media session if activity is still in a valid state
|
||||
if (!isFinishing() && !isDestroyed()) {
|
||||
mediaController.setPlayer(player);
|
||||
mediaSession.setActive(true);
|
||||
playerInitialized = true;
|
||||
} else {
|
||||
// Activity is no longer valid, release the player
|
||||
if (player != null) {
|
||||
player.release();
|
||||
player = null;
|
||||
}
|
||||
}
|
||||
|
||||
initializationInProgress = false;
|
||||
return Unit.INSTANCE;
|
||||
}, () -> {
|
||||
initializationInProgress = false;
|
||||
Toast.makeText(this, "Session expired. Please relink your account.", Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,6 +353,8 @@ public class PlaybackActivity extends FragmentActivity {
|
||||
player.stop();
|
||||
player.release();
|
||||
player = null;
|
||||
playerInitialized = false;
|
||||
initializationInProgress = false;
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class SubscriptionHeaderPresenter : RowHeaderPresenter() {
|
||||
.load(
|
||||
GlideUrl(
|
||||
creator.icon?.path, LazyHeaders.Builder()
|
||||
.addHeader("User-Agent", "Hydravion (AndroidTV $version), CFNetwork")
|
||||
.addHeader("User-Agent", "Hydravion (AndroidTV $version)")
|
||||
.build()
|
||||
)
|
||||
)
|
||||
|
||||
6
app/src/main/res/drawable/status_indicator_offline.xml
Normal file
6
app/src/main/res/drawable/status_indicator_offline.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#F44336" />
|
||||
</shape>
|
||||
|
||||
6
app/src/main/res/drawable/status_indicator_online.xml
Normal file
6
app/src/main/res/drawable/status_indicator_online.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#4CAF50" />
|
||||
</shape>
|
||||
|
||||
128
app/src/main/res/layout/activity_qr_login.xml
Normal file
128
app/src/main/res/layout/activity_qr_login.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#1a1a1a"
|
||||
android:padding="48dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Login"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="36sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<!-- Left Column: Instructions -->
|
||||
<ScrollView
|
||||
android:id="@+id/instructions_scroll"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="48dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/right_column"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title_text">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/instructions_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Scan the QR code or visit floatplane.com/link on another device to log in to your Floatplane account."
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp"
|
||||
android:lineSpacingMultiplier="1.2"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="How to link your account:"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cookie_steps"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1. Using a browser, go to floatplane.com/link\n2. When prompted, enter the code shown on this screen\n3. Once complete, this app will sign in automatically"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="16sp"
|
||||
android:lineSpacingMultiplier="1.3"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/user_code_display"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:gravity="center"
|
||||
android:background="#333333"
|
||||
android:padding="20dp"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Requesting login code..."
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="16sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- Right Column: Server Status, QR Code, URL -->
|
||||
<LinearLayout
|
||||
android:id="@+id/right_column"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="48dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/instructions_scroll"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title_text">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_code"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:adjustViewBounds="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/timer_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Time Remaining: 10:00"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.0.1'
|
||||
classpath 'com.android.tools.build:gradle:8.13.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Sat Apr 17 08:39:50 CDT 2021
|
||||
#Sat Nov 08 18:41:15 EST 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
||||
|
||||
Reference in New Issue
Block a user