feat: add automatic hourly resolver health-check and UI toggle

This commit is contained in:
Sarto
2026-04-14 17:51:16 +03:30
parent 9ab82c33ba
commit 6c1765e881
7 changed files with 158 additions and 18 deletions
@@ -79,12 +79,16 @@ class MainActivity : ComponentActivity() {
private fun registerBackHandler() {
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
} else {
// No WebView history to go back to — move app to background
// instead of finishing the activity (keeps the service alive).
moveTaskToBack(true)
// Check if the chat view is open (mobile nav). If yes, go back
// to the channel list. If already on the channel list, minimize.
webView.evaluateJavascript(
"(document.getElementById('app').classList.contains('chat-open')).toString()"
) { result ->
if (result.trim('"') == "true") {
webView.goBack()
} else {
moveTaskToBack(true)
}
}
}
})
@@ -100,22 +104,43 @@ class MainActivity : ComponentActivity() {
}
}
private var batteryOptRequested = false
@Suppress("BatteryLife")
private fun requestDisableBatteryOptimization() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
try {
startActivity(intent)
} catch (_: Exception) {
// Some devices don't support this intent
if (pm.isIgnoringBatteryOptimizations(packageName)) return
val prefs = getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.getBoolean(PREF_BATTERY_OPT_DECLINED, false)) return
batteryOptRequested = true
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
try {
startActivity(intent)
} catch (_: Exception) {
batteryOptRequested = false
}
}
}
override fun onResume() {
super.onResume()
if (batteryOptRequested) {
batteryOptRequested = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
// User declined — save preference so we don't ask again
getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(PREF_BATTERY_OPT_DECLINED, true).apply()
}
}
}
}
}
}
private fun startThefeedService() {
val intent = Intent(this, ThefeedService::class.java)
@@ -265,6 +290,7 @@ class MainActivity : ComponentActivity() {
private const val PROBE_INTERVAL_MS = 1000L // 1s between probes → up to 30s total
private const val PROBE_TIMEOUT_MS = 1000L // 1s HTTP connect timeout per probe
private const val RETRY_DELAY_MS = 2000L // delay before restarting probe cycle on error
private const val PREF_BATTERY_OPT_DECLINED = "battery_opt_declined"
}
}
+13 -2
View File
@@ -22,6 +22,7 @@ type ResolverChecker struct {
timeout time.Duration
logFunc LogFunc
onScanDone func([]string) // called after each completed scan with healthy resolvers
autoScan bool // if true, run hourly periodic scans
started atomic.Bool // guards against double-start
scanMu sync.Mutex // protects scanCancel
scanRunMu sync.Mutex // only one CheckNow at a time (via TryLock)
@@ -51,6 +52,11 @@ func (rc *ResolverChecker) SetOnScanDone(fn func([]string)) {
rc.onScanDone = fn
}
// SetAutoScan enables or disables the hourly periodic health-check loop.
func (rc *ResolverChecker) SetAutoScan(enabled bool) {
rc.autoScan = enabled
}
// Start begins the periodic health-check loop in the background.
// An initial check runs immediately; subsequent checks happen every 10 minutes.
// ctx controls the lifetime — cancel it to stop the checker.
@@ -90,7 +96,9 @@ func (rc *ResolverChecker) StartAndNotify(ctx context.Context, onFirstDone func(
onFirstDone()
}
rc.runPeriodicLoop(ctx)
if rc.autoScan {
rc.runPeriodicLoop(ctx)
}
}()
}
@@ -102,7 +110,9 @@ func (rc *ResolverChecker) StartPeriodic(ctx context.Context) {
if !rc.started.CompareAndSwap(false, true) {
return
}
go rc.runPeriodicLoop(ctx)
if rc.autoScan {
go rc.runPeriodicLoop(ctx)
}
}
// runPeriodicLoop is the shared Hour ticker loop used by both
@@ -238,6 +248,7 @@ func (rc *ResolverChecker) CheckNow(ctx context.Context) bool {
wg.Wait()
if scanCtx.Err() != nil {
rc.log("RESOLVER_SCAN cancelled")
return false // context cancelled — don't update resolver list
}
+49 -1
View File
@@ -14,6 +14,8 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"golang.org/x/net/html"
@@ -247,7 +249,20 @@ func (xr *XPublicReader) fetchAccount(ctx context.Context, username string) ([]p
lastErr = fmt.Errorf("%s: %w", instance, err)
continue
}
return msgs, nil
// Filter out garbled messages (invalid UTF-8 or mostly non-printable).
cleaned := msgs[:0]
for _, m := range msgs {
if isReadableText(m.Text) {
cleaned = append(cleaned, m)
} else {
log.Printf("[x] @%s: skipping garbled message ID=%d (len=%d)", username, m.ID, len(m.Text))
}
}
if len(cleaned) == 0 {
lastErr = fmt.Errorf("%s: all %d messages were garbled", instance, len(msgs))
continue
}
return cleaned, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no Nitter instances configured")
@@ -271,6 +286,7 @@ type xRSSItem struct {
}
func parseXRSSMessages(body []byte, feedUser string) ([]protocol.Message, error) {
body = sanitizeUTF8(body)
var feed xRSS
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("parse rss: %w", err)
@@ -542,3 +558,35 @@ func formatXQuoteMarkers(s string) string {
}
return strings.TrimSpace(b.String())
}
// isReadableText returns true if s is valid UTF-8 and at least half of its
// runes are printable (letters, digits, punctuation, symbols, spaces).
// This filters out garbled binary data that some Nitter instances return.
func isReadableText(s string) bool {
if s == "" {
return false
}
if !utf8.ValidString(s) {
return false
}
var total, printable int
for _, r := range s {
total++
if unicode.IsPrint(r) || r == '\n' || r == '\r' || r == '\t' {
printable++
}
}
if total == 0 {
return false
}
return float64(printable)/float64(total) >= 0.5
}
// sanitizeUTF8 replaces invalid UTF-8 sequences with the Unicode replacement
// character so xml.Unmarshal doesn't choke on broken encodings.
func sanitizeUTF8(data []byte) []byte {
if utf8.Valid(data) {
return data
}
return []byte(strings.ToValidUTF8(string(data), "\uFFFD"))
}
+21
View File
@@ -256,3 +256,24 @@ func TestParseXRSSMessages_PureRetweet(t *testing.T) {
t.Fatalf("original content missing; got: %q", text)
}
}
func TestIsReadableText(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"Hello world", true},
{"سلام دنیا", true},
{"", false},
{"\x00\x01\x02\x03\x04\x05", false},
{"Y;\x80\x81 $ \x82) \x83\x84", false},
{"abc\x00\x01\x02\x03\x04\x05\x06\x07\x08", false},
{"Hello\nWorld\t!", true},
}
for _, tt := range tests {
got := isReadableText(tt.input)
if got != tt.want {
t.Errorf("isReadableText(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
+17
View File
@@ -101,6 +101,15 @@ func (s *Server) handleScannerStart(w http.ResponseWriter, r *http.Request) {
Passphrase: profileCfg.Key,
}
// Cancel any in-progress resolver checker scan to avoid resource
// contention (both the checker and scanner do DNS probes).
s.mu.RLock()
checker := s.checker
s.mu.RUnlock()
if checker != nil {
checker.CancelCurrentScan()
}
s.scanner.SetLogFunc(func(msg string) {
s.addLog(msg)
})
@@ -244,6 +253,14 @@ func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
s.config = cfg
s.mu.Unlock()
}
// Cancel any in-progress checker scan before re-initializing so the
// old goroutine exits quickly and doesn't race with the new fetcher.
s.mu.RLock()
oldChecker := s.checker
s.mu.RUnlock()
if oldChecker != nil {
oldChecker.CancelCurrentScan()
}
if err := s.initFetcher(); err != nil {
http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
return
+12 -1
View File
@@ -1643,6 +1643,7 @@
value="4" min="1" max="5" title="How many resolvers are queried at the same time for one block"></div>
<div class="form-group"><label data-i18n="dns_timeout">DNS Query Timeout (seconds)</label><input type="number" id="peTimeout"
value="15" min="1" max="60" step="1"></div>
<div class="form-group" style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="peAutoScan" style="width:auto" checked><label for="peAutoScan" data-i18n="auto_scan">Automatic hourly resolver check</label></div>
<!-- Channel Management (editing only) -->
<div id="peChannelSection" style="display:none">
<hr class="section-divider">
@@ -1930,6 +1931,7 @@
apply: 'اعمال',
clear_bg: 'پاک کردن',
dns_timeout: 'تایم‌اوت DNS (ثانیه)',
auto_scan: 'بررسی خودکار ریزالورها (هر ساعت)',
scanner_clear_targets: '\uD83D\uDDD1 پاک کردن',
},
en: {
@@ -2041,6 +2043,7 @@
apply: 'Apply',
clear_bg: 'Clear',
dns_timeout: 'DNS Query Timeout (s)',
auto_scan: 'Automatic hourly resolver check',
scanner_clear_targets: '\uD83D\uDDD1 Clear',
}
};
@@ -2497,6 +2500,7 @@
document.getElementById('peRateLimit').value = p.config.rateLimit || 6;
document.getElementById('peScatter').value = p.config.scatter || 4;
document.getElementById('peTimeout').value = p.config.timeout || 15;
document.getElementById('peAutoScan').checked = p.config.autoScan !== false;
}
document.getElementById('peChannelSection').style.display = '';
var isActive = id === activeProfileId;
@@ -2521,6 +2525,7 @@
document.getElementById('peRateLimit').value = '6';
document.getElementById('peScatter').value = '4';
document.getElementById('peTimeout').value = '15';
document.getElementById('peAutoScan').checked = true;
document.getElementById('peChannelSection').style.display = 'none';
}
}
@@ -2569,7 +2574,7 @@
var key = document.getElementById('peKey').value;
var resolvers = document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function (s) { return s.trim() }).filter(Boolean);
if (!domain || !key || !resolvers.length) { errEl.textContent = t('resolvers') + ' / ' + t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15 } };
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15, autoScan: document.getElementById('peAutoScan').checked ? undefined : false } };
var action = editingProfileId ? 'update' : 'create';
var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0;
// Check if we should skip resolver check (existing healthy resolvers)
@@ -2953,6 +2958,12 @@
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = resolverScanHint;
return;
}
// RESOLVER_SCAN cancelled
if (line.includes('RESOLVER_SCAN cancelled')) {
resolverScanHint = '';
if (item && item.parentNode) item.parentNode.removeChild(item);
return;
}
// RESOLVER_SCAN done K/T
var doneMatch = line.match(/RESOLVER_SCAN done (\d+)\/(\d+)/);
if (doneMatch) {
+6
View File
@@ -41,6 +41,9 @@ type Config struct {
// Scatter is the number of resolvers queried simultaneously per DNS block request
// (0 or 1 = sequential, 4 = default parallel pair).
Scatter int `json:"scatter,omitempty"`
// AutoScan enables hourly automatic resolver health-check scans.
// nil means enabled (default); set to a false pointer to disable.
AutoScan *bool `json:"autoScan,omitempty"`
}
// Profile wraps a Config with a user-chosen nickname and a unique ID.
@@ -709,6 +712,9 @@ func (s *Server) initFetcher() error {
s.saveLastScan(healthy)
}
})
// nil means enabled (the default); only an explicit false pointer disables it.
autoScan := cfg.AutoScan == nil || *cfg.AutoScan
checker.SetAutoScan(autoScan)
s.checker = checker
s.fetcher = fetcher