Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3c5e6ea1 | |||
| 895a4ff3cd | |||
| e2747597e2 | |||
| 6934017519 | |||
| ee68b11ad0 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "me.khodak.claudeusage"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 9
|
||||
versionName = "1.8"
|
||||
versionCode = 11
|
||||
versionName = "1.10"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -44,6 +44,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ClaudeUsage"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.view.View
|
||||
import android.webkit.*
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
@@ -72,8 +72,8 @@ class LoginActivity : AppCompatActivity() {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
databaseEnabled = false
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
// Standard Android Chrome UA — less suspicious than desktop
|
||||
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
||||
@@ -145,7 +145,7 @@ class LoginActivity : AppCompatActivity() {
|
||||
UsageUpdateWorker.triggerImmediateRefresh(this)
|
||||
UsageUpdateWorker.schedulePeriodicRefresh(this)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = UsageRepository(prefs).fetchUsage()
|
||||
prefs.saveUsageData(data)
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.khodak.claudeusage
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.khodak.claudeusage.BuildConfig
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
import me.khodak.claudeusage.data.UsageData
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -67,13 +68,13 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
||||
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
||||
val code = resp.code
|
||||
Log.d(TAG, "GET $usageUrl → $code")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "GET $usageUrl → $code")
|
||||
if (code == 401 || code == 403) {
|
||||
prefs.clearSession()
|
||||
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
||||
}
|
||||
val body = resp.body?.string() ?: ""
|
||||
debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
||||
val utilData = tryParseUtilizationBody(body)
|
||||
if (utilData != null) {
|
||||
return@withContext base.copy(
|
||||
@@ -98,7 +99,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
val req = buildRequest(url, cookies)
|
||||
val resp = client.newCall(req).execute()
|
||||
val code = resp.code
|
||||
Log.d(TAG, "GET $url → $code")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code")
|
||||
|
||||
if (code == 401 || code == 403) {
|
||||
prefs.clearSession()
|
||||
@@ -107,8 +108,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
|
||||
val rateLimitData = extractRateLimitHeaders(resp.headers)
|
||||
val body = resp.body?.string() ?: ""
|
||||
debugBuf.append("$url\n→ $code: ${body.take(300)}\n\n")
|
||||
Log.d(TAG, "Body: ${body.take(300)}")
|
||||
if (BuildConfig.DEBUG) {
|
||||
debugBuf.append("$url\n→ $code: ${body.take(300)}\n\n")
|
||||
Log.d(TAG, "Body: ${body.take(300)}")
|
||||
}
|
||||
|
||||
val parsed = tryParseUsageBody(body, rateLimitData)
|
||||
if (parsed.hasRateLimitData) {
|
||||
@@ -121,8 +124,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
||||
debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
|
||||
if (BuildConfig.DEBUG) Log.w(TAG, "Endpoint $url failed: ${e.message}")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("$url\n→ ERROR: ${e.message}\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +134,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
val req = buildRequest("https://claude.ai/api/me", cookies)
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
debugBuf.append("https://claude.ai/api/me\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||
if (resp.isSuccessful && body.isNotBlank() && !body.startsWith("<")) {
|
||||
val json = JSONObject(body)
|
||||
val parsed = tryParseOrgForUsage(json)
|
||||
@@ -145,7 +148,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/me → ERROR: ${e.message}\n\n")
|
||||
}
|
||||
|
||||
// Step 4: try page HTML for __NEXT_DATA__
|
||||
@@ -169,7 +172,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
val marker = """<script id="__NEXT_DATA__" type="application/json">"""
|
||||
val start = html.indexOf(marker)
|
||||
if (start < 0) {
|
||||
debugBuf.append("HTML: no __NEXT_DATA__ found\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("HTML: no __NEXT_DATA__ found\n")
|
||||
return null
|
||||
}
|
||||
val jsonStart = start + marker.length
|
||||
@@ -177,10 +180,10 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
if (jsonEnd < 0) return null
|
||||
val nextData = JSONObject(html.substring(jsonStart, jsonEnd))
|
||||
val topKeys = nextData.keys().asSequence().toList()
|
||||
debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("HTML __NEXT_DATA__ top keys: $topKeys\n")
|
||||
tryExtractFromNextData(nextData)
|
||||
} catch (e: Exception) {
|
||||
debugBuf.append("HTML scrape error: ${e.message}\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("HTML scrape error: ${e.message}\n")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -200,7 +203,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
obj = (obj as? JSONObject)?.opt(key)
|
||||
}
|
||||
val o = obj as? JSONObject ?: continue
|
||||
debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("NextData at [${path.joinToString(".")}]: ${o.toString().take(300)}\n")
|
||||
val usage = tryParseOrgForUsage(o) ?: tryParseUsageBody(o.toString(), UsageData())
|
||||
if (usage.hasRateLimitData) return usage
|
||||
}
|
||||
@@ -210,9 +213,9 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
private fun fetchOrgInfo(cookies: String): Pair<String?, UsageData?> {
|
||||
return try {
|
||||
val resp = client.newCall(buildRequest("https://claude.ai/api/organizations", cookies)).execute()
|
||||
Log.d(TAG, "Orgs → ${resp.code}")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Orgs → ${resp.code}")
|
||||
val body = resp.body?.string() ?: return Pair(null, null)
|
||||
debugBuf.append("https://claude.ai/api/organizations\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||
if (BuildConfig.DEBUG) debugBuf.append("https://claude.ai/api/organizations\n→ ${resp.code}: ${body.take(400)}\n\n")
|
||||
if (body.isBlank() || body.startsWith("<")) return Pair(null, null)
|
||||
val arr = JSONArray(body)
|
||||
val org = arr.optJSONObject(0) ?: return Pair(null, null)
|
||||
@@ -332,7 +335,7 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
||||
.header("Accept", "application/json, */*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Referer", "https://claude.ai/")
|
||||
.header("Cookie", cookies)
|
||||
.header("Cookie", cookies.replace("\r", "").replace("\n", ""))
|
||||
.get()
|
||||
.build()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.khodak.claudeusage.data.PreferencesManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UsageUpdateWorker(
|
||||
private val context: Context,
|
||||
@@ -77,21 +78,39 @@ class UsageUpdateWorker(
|
||||
|
||||
companion object {
|
||||
private const val WORK_ONE_SHOT = "claude_oneshot"
|
||||
private const val WORK_PERIODIC = "claude_periodic"
|
||||
private const val ALARM_CODE = 1001
|
||||
private const val INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
fun schedulePeriodicRefresh(context: Context) {
|
||||
// 5-min alarm for fast updates when the device is active/awake
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
am.setAndAllowWhileIdle(
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
SystemClock.elapsedRealtime() + INTERVAL_MS,
|
||||
alarmIntent(context)
|
||||
)
|
||||
// WorkManager periodic as a Doze/background backup (Android 16 reliability).
|
||||
// WorkManager uses JobScheduler which survives Doze; AlarmManager may be batched
|
||||
// up to 75 min in Doze mode. KEEP = don't reset the 15-min timer on every alarm.
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_PERIODIC,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<UsageUpdateWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelPeriodicRefresh(context: Context) =
|
||||
fun cancelPeriodicRefresh(context: Context) {
|
||||
(context.getSystemService(Context.ALARM_SERVICE) as AlarmManager)
|
||||
.cancel(alarmIntent(context))
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_PERIODIC)
|
||||
}
|
||||
|
||||
fun triggerImmediateRefresh(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
|
||||
@@ -10,11 +10,14 @@ class PreferencesManager(context: Context) {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val securePrefs = createSecurePrefs(context)
|
||||
private var usingFallbackPrefs = false
|
||||
private val securePrefs = createSecurePrefs(context, onFallback = { usingFallbackPrefs = true })
|
||||
|
||||
private val prefs = context.getSharedPreferences("claude_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
fun saveCookies(cookies: String) {
|
||||
// Never store cookies in plain-text fallback prefs
|
||||
if (usingFallbackPrefs) return
|
||||
try {
|
||||
securePrefs.edit().putString(KEY_COOKIES, cookies).apply()
|
||||
} catch (_: Exception) {}
|
||||
@@ -78,34 +81,46 @@ class PreferencesManager(context: Context) {
|
||||
private const val KEY_ACTIVE_WEEK = "active_week"
|
||||
private const val KEY_ACTIVE_MASK = "active_mask"
|
||||
|
||||
private fun createSecurePrefs(context: Context): android.content.SharedPreferences {
|
||||
// First attempt: normal open
|
||||
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
||||
return try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context, "claude_secure", masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Keystore key was invalidated (e.g., user added/changed biometrics or screen lock).
|
||||
// Delete the stale encrypted file and recreate — user will need to re-login.
|
||||
try {
|
||||
context.deleteSharedPreferences("claude_secure")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context, "claude_secure", masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
buildEncryptedPrefs(context)
|
||||
} catch (e: Exception) {
|
||||
if (isKeyPermanentlyInvalidated(e)) {
|
||||
// Key permanently gone (biometric/PIN changed) — must wipe; user must re-login.
|
||||
try {
|
||||
context.deleteSharedPreferences("claude_secure")
|
||||
buildEncryptedPrefs(context)
|
||||
} catch (_: Exception) {
|
||||
onFallback?.invoke()
|
||||
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
|
||||
}
|
||||
} else {
|
||||
// Transient failure (Keystore busy, cold boot, screen locked during BG work).
|
||||
// Do NOT delete the encrypted file — it will be readable next session.
|
||||
onFallback?.invoke()
|
||||
context.getSharedPreferences("claude_secure_fallback", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEncryptedPrefs(context: Context): android.content.SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context, "claude_secure", masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
private fun isKeyPermanentlyInvalidated(e: Exception): Boolean {
|
||||
var t: Throwable? = e
|
||||
while (t != null) {
|
||||
if (t is android.security.keystore.KeyPermanentlyInvalidatedException) return true
|
||||
t = t.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@android:drawable/ic_menu_rotate"
|
||||
android:background="@android:color/transparent"
|
||||
android:tint="#999999"
|
||||
@@ -54,7 +54,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SESSION"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -86,8 +86,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text=""
|
||||
android:textColor="#666666"
|
||||
android:textSize="9sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- 7-day window bar -->
|
||||
<LinearLayout
|
||||
@@ -102,7 +103,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="WEEKLY"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -134,8 +135,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text=""
|
||||
android:textColor="#666666"
|
||||
android:textSize="9sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Footer -->
|
||||
<LinearLayout
|
||||
@@ -151,8 +153,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text=""
|
||||
android:textColor="#CC785C"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
@@ -161,8 +164,9 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textColor="#444444"
|
||||
android:textSize="9sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:src="@android:drawable/ic_menu_rotate"
|
||||
android:background="@android:color/transparent"
|
||||
android:contentDescription="Refresh" />
|
||||
@@ -46,7 +46,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SESSION"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -65,8 +65,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text=""
|
||||
android:textColor="#555555"
|
||||
android:textSize="8sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -94,7 +95,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="WEEKLY"
|
||||
android:textColor="#555555"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
@@ -113,8 +114,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:text=""
|
||||
android:textColor="#555555"
|
||||
android:textSize="8sp" />
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -135,8 +137,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text=""
|
||||
android:textColor="#CC785C"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="1" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -11,6 +11,6 @@
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:initialLayout="@layout/widget_layout"
|
||||
android:widgetCategory="home_screen|keyguard"
|
||||
android:widgetCategory="home_screen"
|
||||
android:description="@string/widget_description"
|
||||
android:previewLayout="@layout/widget_layout" />
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user