From f9061a6b041afa1f3fda50c177886e7718e28f90 Mon Sep 17 00:00:00 2001 From: reddaniel <48639104@qq.com> Date: Mon, 26 Oct 2026 14:04:56 +0800 Subject: [PATCH] OpenTerm v0.3.0 INTL: global data sources (Yahoo/Stooq/Tencent), EN/ZH UI, symbol search, watchlist persistence, world clocks, intraday chart, news feeds --- .gitignore | 5 + AndroidManifest.xml | 22 + build.sh | 34 + res/drawable/ic_launcher.xml | 12 + res/values/strings.xml | 4 + src/com/dsh/openterm/MainActivity.java | 1019 ++++++++++++++++++++++++ 6 files changed, 1096 insertions(+) create mode 100644 .gitignore create mode 100644 AndroidManifest.xml create mode 100644 build.sh create mode 100644 res/drawable/ic_launcher.xml create mode 100644 res/values/strings.xml create mode 100644 src/com/dsh/openterm/MainActivity.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2ceee7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +OpenTermBuild/ +*.apk +*.keystore +gen/ +classes/ diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..5806c25 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..e9fc13d --- /dev/null +++ b/build.sh @@ -0,0 +1,34 @@ +#!/data/data/com.dsharnessmobile.shell/files/usr/bin/bash +# OpenTerm 一键构建脚本 +set -e +P=/data/data/com.dsharnessmobile.shell/files/usr +J=$P/lib/jvm/java-17-openjdk/bin +export PATH=$J:$P/bin:$PATH +SRC=/data/data/com.dsharnessmobile.shell/files/home/.dsh/workspaces/incoming/OpenTerm +OUT=/data/data/com.dsharnessmobile.shell/files/home/OpenTermBuild +FWRES=/system/framework/framework-res.apk + +cd "$OUT" +rm -rf src gen classes classes.dex unsigned.apk OpenTerm.apk +mkdir -p src/com/dsh/openterm gen/com/dsh/openterm classes +cp -r "$SRC"/src/com/dsh/openterm/* src/com/dsh/openterm/ +cp "$SRC"/AndroidManifest.xml . + +# 1. 资源 + R.java +aapt package -f -M AndroidManifest.xml -S "$SRC/res" -I "$FWRES" -J gen +# 2. 编译 +javac --release 8 -classpath "$HOME/android-stub.jar:$HOME/json.jar" -d classes \ + src/com/dsh/openterm/*.java $(find gen -name "*.java") +# 3. 转 dex +java -cp "$HOME/r8.jar" com.android.tools.r8.D8 --release \ + --lib "$HOME/android-stub.jar" --output . classes/com/dsh/openterm/*.class +# 4. 打包 +aapt package -f -M AndroidManifest.xml -S "$SRC/res" -I "$FWRES" -F unsigned.apk +aapt add unsigned.apk classes.dex +# 5. 签名 +apksigner sign --ks "$HOME/openterm.keystore" --ks-pass pass:openterm123 \ + --key-pass pass:openterm123 --out OpenTerm.apk unsigned.apk +apksigner verify OpenTerm.apk +cp OpenTerm.apk "$SRC/../OpenTerm.apk" +ls -la "$SRC/../OpenTerm.apk" +echo BUILD_OK diff --git a/res/drawable/ic_launcher.xml b/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..16ac765 --- /dev/null +++ b/res/drawable/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..ba18bcd --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,4 @@ + + + OpenTerm 行情终端 + diff --git a/src/com/dsh/openterm/MainActivity.java b/src/com/dsh/openterm/MainActivity.java new file mode 100644 index 0000000..fb595ef --- /dev/null +++ b/src/com/dsh/openterm/MainActivity.java @@ -0,0 +1,1019 @@ +package com.dsh.openterm; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Typeface; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.BaseAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class MainActivity extends Activity implements View.OnClickListener { + + /* ══════════ i18n ══════════ */ + static boolean zh() { return Locale.getDefault().getLanguage().startsWith("zh"); } + static String t(String z, String e) { return zh() ? z : e; } + + /* ══════════ 数据模型 ══════════ */ + + static class Quote { + String sym; // 显示符号 AAPL / BMW.DE / BTC-USD + String yahoo; // Yahoo 代码 + String tencent; // 腾讯代码(国内回落,可空) + String name = ""; + String price = "--", change = "--", pct = "--"; + double priceNum = Double.NaN, prevNum = Double.NaN; + String highS = "--", lowS = "--", volS = "--"; + String currency = "", exchange = ""; + boolean up, loaded; + Quote(String yahoo, String tencent, String name) { + this.yahoo = yahoo; this.tencent = tencent; this.name = name; + this.sym = yahoo; + } + } + + static class MPoint { double t; double price; } + + static class NewsItem { String time = "", title = "", link = ""; } + + static class SearchHit { String symbol, name, exchange, type; } + + /* ══════════ 默认自选(全球均衡) ══════════ */ + static final String[][] DEFAULTS = { + {"^GSPC", "usSPX", "S&P 500"}, + {"^IXIC", "usIXIC", "Nasdaq Composite"}, + {"^DJI", "usDJI", "Dow Jones"}, + {"^HSI", "r_hkHSI", "Hang Seng"}, + {"^N225", null, "Nikkei 225"}, + {"^STOXX50E", null, "Euro Stoxx 50"}, + {"AAPL", "usAAPL", "Apple"}, + {"NVDA", "usNVDA", "NVIDIA"}, + {"MSFT", null, "Microsoft"}, + {"BMW.DE", null, "BMW"}, + {"ASML.AS",null, "ASML"}, + {"BTC-USD",null, "Bitcoin"}, + {"ETH-USD",null, "Ethereum"}, + {"EURUSD=X",null, "EUR/USD"}, + }; + + /* ══════════ 配色 ══════════ */ + static final int BG = 0xFF0A0A0A, PANEL = 0xFF141414, BAR = 0xFF1B1B1B; + static final int ORANGE = 0xFFFF9900, AMBER = 0xFFFFD580; + static final int RED = 0xFFE53935, GREEN = 0xFF00C853; + static final int WHITE = 0xFFE8E8E8, GREY = 0xFF808080, LINE = 0xFF2A2A2A; + + /* ══════════ 状态 ══════════ */ + static final int PAGE_WATCH = 0, PAGE_NEWS = 1, PAGE_SEARCH = 2, PAGE_DETAIL = 3; + int page = PAGE_WATCH; + final List quotes = new ArrayList<>(); + final List news = new ArrayList<>(); + final List detailNews = new ArrayList<>(); + final List hits = new ArrayList<>(); + Quote detail; + + QuoteAdapter quoteAdapter; + NewsAdapter newsAdapter, detailNewsAdapter; + HitAdapter hitAdapter; + ListView quoteList, newsList, hitList, dNewsList; + LinearLayout pageWatch, pageNews, pageSearch, pageDetail; + TextView tvClock, tvStatus, tvStats, tvTicker; + TextView tabWatch, tabNews, tabSearch; + TextView dTitle, dName, dCode, dPrice, dChange, dO, dH, dL, dP, dV, dUpdated, dNewsHead; + EditText searchBox; + MinuteChart chart; + LinearLayout clocksBar; + + final Handler ui = new Handler(Looper.getMainLooper()); + final ExecutorService net = Executors.newSingleThreadExecutor(); + final SimpleDateFormat hm = new SimpleDateFormat("HH:mm:ss", Locale.US); + SharedPreferences prefs; + + @Override protected void onCreate(Bundle b) { + super.onCreate(b); + prefs = getSharedPreferences("openterm", MODE_PRIVATE); + loadWatchlist(); + buildUi(); + refreshQuotes(); + ui.postDelayed(quoteLoop, 30000); + ui.postDelayed(tick, 1000); + ui.postDelayed(newsLoop, 1000); + } + + final Runnable tick = new Runnable() { @Override public void run() { + tvClock.setText(hm.format(new Date()) + " LOCAL"); + renderClocks(); + ui.postDelayed(this, 1000); + }}; + + final Runnable quoteLoop = new Runnable() { @Override public void run() { + refreshQuotes(); ui.postDelayed(this, 30000); + }}; + + final Runnable newsLoop = new Runnable() { @Override public void run() { + if (page == PAGE_NEWS || news.isEmpty()) fetchNews(); + ui.postDelayed(this, 90000); + }}; + + /* ══════════ 自选持久化 ══════════ */ + + void loadWatchlist() { + quotes.clear(); + String s = prefs.getString("watchlist", null); + try { + if (s != null) { + JSONArray a = new JSONArray(s); + for (int i = 0; i < a.length(); i++) { + JSONObject o = a.getJSONObject(i); + String tc = o.optString("t", ""); + quotes.add(new Quote(o.getString("y"), tc.isEmpty() ? null : tc, o.getString("n"))); + } + if (!quotes.isEmpty()) return; + } + } catch (Exception ignored) { } + for (String[] d : DEFAULTS) quotes.add(new Quote(d[0], d[1], d[2])); + } + + void saveWatchlist() { + try { + JSONArray a = new JSONArray(); + for (Quote q : quotes) { + JSONObject o = new JSONObject(); + o.put("y", q.yahoo); + o.put("t", q.tencent == null ? "" : q.tencent); + o.put("n", q.name); + a.put(o); + } + prefs.edit().putString("watchlist", a.toString()).apply(); + } catch (Exception ignored) { } + } + + /* ══════════ UI ══════════ */ + + void buildUi() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(BG); + + LinearLayout bar = new LinearLayout(this); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setBackgroundColor(BAR); + bar.setPadding(dp(12), dp(10), dp(12), dp(10)); + bar.addView(mono("OPENTERM", 20, ORANGE, Typeface.BOLD), + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + tvClock = mono("--:--:--", 13, AMBER, Typeface.NORMAL); + LinearLayout.LayoutParams cp = wrapWrap(); cp.gravity = Gravity.CENTER_VERTICAL; + bar.addView(tvClock, cp); + root.addView(bar, matchWrap()); + + clocksBar = new LinearLayout(this); + clocksBar.setOrientation(LinearLayout.HORIZONTAL); + clocksBar.setBackgroundColor(PANEL); + clocksBar.setPadding(dp(12), dp(3), dp(12), dp(3)); + root.addView(clocksBar, matchWrap()); + + LinearLayout nav = new LinearLayout(this); + nav.setOrientation(LinearLayout.HORIZONTAL); + nav.setBackgroundColor(PANEL); + nav.setPadding(dp(8), dp(4), dp(8), dp(0)); + tabWatch = tabBtn("1 " + t("自选", "WATCH"), PAGE_WATCH); + tabNews = tabBtn("2 " + t("快讯", "NEWS"), PAGE_NEWS); + tabSearch= tabBtn("3 " + t("搜索", "SEARCH"), PAGE_SEARCH); + nav.addView(tabWatch, navLP()); nav.addView(tabNews, navLP()); nav.addView(tabSearch, navLP()); + root.addView(nav, matchWrap()); + + LinearLayout sub = new LinearLayout(this); + sub.setOrientation(LinearLayout.HORIZONTAL); + sub.setBackgroundColor(PANEL); + sub.setPadding(dp(12), dp(3), dp(12), dp(4)); + tvStatus = mono("", 11, GREY, Typeface.NORMAL); + sub.addView(tvStatus, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + tvStats = mono("", 11, AMBER, Typeface.BOLD); + sub.addView(tvStats, wrapWrap()); + root.addView(sub, matchWrap()); + + pageWatch = buildWatchPage(); + pageNews = buildNewsPage(); + pageSearch = buildSearchPage(); + pageDetail = buildDetailPage(); + for (LinearLayout p : new LinearLayout[]{pageWatch, pageNews, pageSearch, pageDetail}) + root.addView(p, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + + tvTicker = mono(" OPENTERM 0.3 INTL · " + + t("数据:Yahoo Finance 公开接口", "Data: Yahoo Finance public API") + + " · " + t("仅供参考,不构成投资建议", "For information only, not investment advice") + " ", + 12, AMBER, Typeface.NORMAL); + tvTicker.setBackgroundColor(BAR); + tvTicker.setPadding(dp(8), dp(6), dp(8), dp(6)); + root.addView(tvTicker, matchWrap()); + + setContentView(root); + showPage(PAGE_WATCH); + } + + LinearLayout.LayoutParams matchWrap() { + return new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + LinearLayout.LayoutParams wrapWrap() { + return new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + LinearLayout.LayoutParams navLP() { + LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1); + p.setMargins(dp(4), 0, dp(4), 0); + return p; + } + + TextView tabBtn(String label, final int target) { + TextView tv = new TextView(this); + tv.setText(label); tv.setTextSize(12); + tv.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + tv.setGravity(Gravity.CENTER); + tv.setPadding(dp(6), dp(6), dp(6), dp(6)); + tv.setOnClickListener(this); + tv.setTag(target); + tv.setBackgroundColor(PANEL); + return tv; + } + + LinearLayout buildWatchPage() { + LinearLayout p = new LinearLayout(this); + p.setOrientation(LinearLayout.VERTICAL); + p.setBackgroundColor(BG); + LinearLayout head = new LinearLayout(this); + head.setOrientation(LinearLayout.HORIZONTAL); + head.setBackgroundColor(0xFF1E1E1E); + head.setPadding(dp(12), dp(5), dp(12), dp(5)); + head.addView(mono(t("名称/代码", "NAME / SYMBOL"), 11, ORANGE, Typeface.BOLD), rowLP(2.4f)); + head.addView(mono(t("最新", "LAST"), 11, ORANGE, Typeface.BOLD), rightLP(1.2f)); + head.addView(mono(t("涨跌", "CHG"), 11, ORANGE, Typeface.BOLD), rightLP(1.1f)); + head.addView(mono(t("幅度", "%CHG"), 11, ORANGE, Typeface.BOLD), rightLP(1.1f)); + p.addView(head, matchWrap()); + quoteAdapter = new QuoteAdapter(); + quoteList = new ListView(this); + quoteList.setAdapter(quoteAdapter); + quoteList.setDivider(new android.graphics.drawable.ColorDrawable(LINE)); + quoteList.setDividerHeight(1); + quoteList.setBackgroundColor(BG); + p.addView(quoteList, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + return p; + } + + LinearLayout buildNewsPage() { + LinearLayout p = new LinearLayout(this); + p.setOrientation(LinearLayout.VERTICAL); + p.setBackgroundColor(BG); + LinearLayout head = new LinearLayout(this); + head.setOrientation(LinearLayout.HORIZONTAL); + head.setBackgroundColor(0xFF1E1E1E); + head.setPadding(dp(12), dp(5), dp(12), dp(5)); + head.addView(mono(t("时间", "TIME"), 11, ORANGE, Typeface.BOLD), rowLP(0.6f)); + head.addView(mono(t("全球财经头条 · 滚动更新", "GLOBAL MARKET NEWS · LIVE"), 11, ORANGE, Typeface.BOLD), rowLP(4f)); + p.addView(head, matchWrap()); + newsAdapter = new NewsAdapter(news); + newsList = new ListView(this); + newsList.setAdapter(newsAdapter); + newsList.setDivider(new android.graphics.drawable.ColorDrawable(LINE)); + newsList.setDividerHeight(1); + newsList.setBackgroundColor(BG); + p.addView(newsList, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + return p; + } + + LinearLayout buildSearchPage() { + LinearLayout p = new LinearLayout(this); + p.setOrientation(LinearLayout.VERTICAL); + p.setBackgroundColor(BG); + searchBox = new EditText(this); + searchBox.setHint(t("输入代码或名称,如 AAPL / BMW.DE / BTC-USD", + "Symbol or name, e.g. AAPL / BMW.DE / BTC-USD")); + searchBox.setTextColor(WHITE); searchBox.setHintTextColor(GREY); + searchBox.setTextSize(13); + searchBox.setTypeface(Typeface.MONOSPACE); + searchBox.setBackgroundColor(PANEL); + searchBox.setPadding(dp(12), dp(10), dp(12), dp(10)); + searchBox.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + searchBox.setSingleLine(); + searchBox.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void onTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void afterTextChanged(Editable s) { + String q = s.toString().trim(); + if (q.length() >= 2) doSearch(q); + } + }); + p.addView(searchBox, matchWrap()); + hitAdapter = new HitAdapter(); + hitList = new ListView(this); + hitList.setAdapter(hitAdapter); + hitList.setDivider(new android.graphics.drawable.ColorDrawable(LINE)); + hitList.setDividerHeight(1); + hitList.setBackgroundColor(BG); + p.addView(hitList, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + return p; + } + + LinearLayout buildDetailPage() { + LinearLayout p = new LinearLayout(this); + p.setOrientation(LinearLayout.VERTICAL); + p.setBackgroundColor(BG); + p.setPadding(0, dp(6), 0, 0); + + dTitle = mono("", 13, ORANGE, Typeface.BOLD); + dTitle.setPadding(dp(12), dp(2), dp(12), dp(2)); + p.addView(dTitle, matchWrap()); + + LinearLayout priceRow = new LinearLayout(this); + priceRow.setOrientation(LinearLayout.HORIZONTAL); + priceRow.setPadding(dp(12), dp(4), dp(12), dp(6)); + dName = mono("", 15, WHITE, Typeface.BOLD); + priceRow.addView(dName, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + dPrice = mono("", 22, WHITE, Typeface.BOLD); + LinearLayout.LayoutParams pp = wrapWrap(); pp.gravity = Gravity.CENTER_VERTICAL; + priceRow.addView(dPrice, pp); + p.addView(priceRow, matchWrap()); + + dChange = mono("", 13, WHITE, Typeface.NORMAL); + dChange.setPadding(dp(12), 0, dp(12), dp(4)); + p.addView(dChange, matchWrap()); + + chart = new MinuteChart(this); + p.addView(chart, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(200))); + + LinearLayout grid = new LinearLayout(this); + grid.setOrientation(LinearLayout.VERTICAL); + grid.setBackgroundColor(PANEL); + LinearLayout r1 = kvRow(), r2 = kvRow(), r3 = kvRow(); + dH = kvCell(r1, t("最高", "HIGH")); dL = kvCell(r1, t("最低", "LOW")); + dP = kvCell(r2, t("昨收", "PREV CLOSE")); dV = kvCell(r2, t("成交量", "VOLUME")); + TextView exch = kvCell(r3, t("交易所", "EXCHANGE")); exch.setTag("exch"); + TextView cur = kvCell(r3, t("币种", "CURRENCY")); cur.setTag("cur"); + grid.addView(r1, matchWrap()); grid.addView(r2, matchWrap()); grid.addView(r3, matchWrap()); + p.addView(grid, matchWrap()); + + dNewsHead = mono(t("相关新闻", "RELATED NEWS"), 11, ORANGE, Typeface.BOLD); + dNewsHead.setPadding(dp(12), dp(8), dp(12), dp(3)); + p.addView(dNewsHead, matchWrap()); + + detailNewsAdapter = new NewsAdapter(detailNews); + dNewsList = new ListView(this); + dNewsList.setAdapter(detailNewsAdapter); + dNewsList.setDivider(new android.graphics.drawable.ColorDrawable(LINE)); + dNewsList.setDividerHeight(1); + dNewsList.setBackgroundColor(BG); + p.addView(dNewsList, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + + dUpdated = mono("", 11, GREY, Typeface.NORMAL); + dUpdated.setPadding(dp(12), dp(3), dp(12), dp(3)); + p.addView(dUpdated, matchWrap()); + + TextView back = mono("<< " + t("返回自选(点击)", "BACK TO WATCHLIST (tap)"), 13, AMBER, Typeface.BOLD); + back.setPadding(dp(12), dp(6), dp(12), dp(10)); + back.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + showPage(PAGE_WATCH); + }}); + p.addView(back, matchWrap()); + return p; + } + + LinearLayout kvRow() { + LinearLayout r = new LinearLayout(this); + r.setOrientation(LinearLayout.HORIZONTAL); + r.setPadding(dp(12), dp(4), dp(12), dp(4)); + return r; + } + + TextView kvCell(LinearLayout parent, String k) { + LinearLayout c = new LinearLayout(this); + c.setOrientation(LinearLayout.VERTICAL); + c.addView(mono(k, 10, GREY, Typeface.NORMAL), wrapWrap()); + TextView vt = mono("--", 13, WHITE, Typeface.BOLD); + c.addView(vt, wrapWrap()); + parent.addView(c, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + return vt; + } + + LinearLayout.LayoutParams rowLP(float w) { + return new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, w); + } + LinearLayout.LayoutParams rightLP(float w) { + LinearLayout.LayoutParams p = rowLP(w); p.gravity = Gravity.RIGHT; + return p; + } + + TextView mono(String s, int sp, int color, int style) { + TextView tv = new TextView(this); + tv.setText(s); tv.setTextSize(sp); tv.setTextColor(color); + tv.setTypeface(Typeface.MONOSPACE, style); + return tv; + } + + int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); } + float dpx(float v) { return v * getResources().getDisplayMetrics().density; } + + void showPage(int which) { + page = which; + pageWatch.setVisibility(which == PAGE_WATCH ? View.VISIBLE : View.GONE); + pageNews.setVisibility(which == PAGE_NEWS ? View.VISIBLE : View.GONE); + pageSearch.setVisibility(which == PAGE_SEARCH ? View.VISIBLE : View.GONE); + pageDetail.setVisibility(which == PAGE_DETAIL ? View.VISIBLE : View.GONE); + tabWatch.setBackgroundColor(which == PAGE_WATCH ? BAR : PANEL); + tabNews.setBackgroundColor(which == PAGE_NEWS ? BAR : PANEL); + tabSearch.setBackgroundColor(which == PAGE_SEARCH ? BAR : PANEL); + tabWatch.setTextColor(which == PAGE_WATCH ? ORANGE : GREY); + tabNews.setTextColor(which == PAGE_NEWS ? ORANGE : GREY); + tabSearch.setTextColor(which == PAGE_SEARCH ? ORANGE : GREY); + } + + @Override public void onClick(View v) { + Object tag = v.getTag(); + if (tag instanceof Integer) showPage((Integer) tag); + } + + /* ══════════ 全球市场时钟条 ══════════ */ + + static final String[][] MKTS = { + {"NEW YORK", "America/New_York", "9.5", "16"}, + {"LONDON", "Europe/London", "8", "16.5"}, + {"TOKYO", "Asia/Tokyo", "9", "15"}, + {"HONGKONG", "Asia/Hong_Kong", "9.5", "16"}, + }; + + void renderClocks() { + clocksBar.removeAllViews(); + for (String[] m : MKTS) { + TimeZone tz = TimeZone.getTimeZone(m[1]); + Calendar c = Calendar.getInstance(tz); + boolean weekday = c.get(Calendar.DAY_OF_WEEK) != Calendar.SATURDAY + && c.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY; + double hr = c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60.0; + boolean open = weekday && hr >= Double.parseDouble(m[2]) && hr < Double.parseDouble(m[3]); + TextView tv = mono("", 10, open ? GREEN : GREY, open ? Typeface.BOLD : Typeface.NORMAL); + tv.setText(String.format(Locale.US, "%s %02d:%02d%s", + m[0], c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE), open ? " ●" : " ○")); + clocksBar.addView(tv, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + } + } + + /* ══════════ HTTP ══════════ */ + + String httpGet(String url) throws Exception { + URL u = new URL(url); + HttpURLConnection c = (HttpURLConnection) u.openConnection(); + c.setConnectTimeout(10000); c.setReadTimeout(10000); + c.setRequestProperty("User-Agent", + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Safari/537.36"); + c.setRequestProperty("Accept", "*/*"); + BufferedReader r = new BufferedReader(new InputStreamReader(c.getInputStream(), "UTF-8")); + StringBuilder sb = new StringBuilder(); String line; + while ((line = r.readLine()) != null) sb.append(line); + r.close(); + return sb.toString(); + } + + /* ══════════ 行情:Yahoo 主源 + Stooq/腾讯兜底 ══════════ */ + + void refreshQuotes() { + setStatus(t("刷新中 …", "Refreshing …")); + net.submit(new Runnable() { @Override public void run() { fetchQuotes(); }}); + } + + void setStatus(final String s) { + ui.post(new Runnable() { @Override public void run() { tvStatus.setText(s); }}); + } + + void fetchQuotes() { + int ok = 0; + for (Quote q : quotes) { + if (fetchYahoo(q)) ok++; + else if (fetchStooq(q)) ok++; + else if (q.tencent != null && fetchTencent(q)) ok++; + } + final int n = ok; + ui.post(new Runnable() { @Override public void run() { + int up = 0, down = 0; + for (Quote q : quotes) { if (!q.loaded) continue; if (q.up) up++; else down++; } + setStatus((n >= quotes.size() ? "" : t("部分失败 · ", "partial · ")) + + t("已更新 ", "updated ") + n + "/" + quotes.size() + " · " + hm.format(new Date())); + tvStats.setText("▲" + up + " ▼" + down); + quoteAdapter.notifyDataSetChanged(); + }}); + } + + /** Yahoo chart 接口:一次返回报价元数据 */ + boolean fetchYahoo(Quote q) { + try { + String json = httpGet("https://query1.finance.yahoo.com/v8/finance/chart/" + + URLEncoder.encode(q.yahoo, "UTF-8") + "?range=1d&interval=5m"); + JSONObject meta = new JSONObject(json) + .getJSONObject("chart").getJSONArray("result").getJSONObject(0) + .getJSONObject("meta"); + double price = meta.optDouble("regularMarketPrice", Double.NaN); + double prev = meta.optDouble("chartPreviousClose", + meta.optDouble("previousClose", Double.NaN)); + if (Double.isNaN(price) || price == 0) return false; + applyQuote(q, price, prev, + meta.optString("currency", ""), meta.optString("exchangeName", ""), + meta.optDouble("regularMarketDayHigh", Double.NaN), + meta.optDouble("regularMarketDayLow", Double.NaN), + meta.optDouble("regularMarketVolume", Double.NaN)); + return true; + } catch (Exception e) { return false; } + } + + /** Stooq 兜底(指数/美股) */ + boolean fetchStooq(Quote q) { + try { + String s = q.yahoo.startsWith("^") ? stooqIndex(q.yahoo) + : q.yahoo.replace('.', '-').toLowerCase(Locale.US) + ".us"; + if (s == null) return false; + String csv = httpGet("https://stooq.com/q/l/?s=" + s + "&f=sd2t2ohlcv&h&e=csv"); + String[] lines = csv.split("\n"); + if (lines.length < 2) return false; + String[] f = lines[1].split(","); + if (f.length < 7) return false; + double close = Double.parseDouble(f[6].trim()); + double open = Double.parseDouble(f[3].trim()); + if (close == 0) return false; + applyQuote(q, close, open, "", "STOOQ", close, close, 0); + return true; + } catch (Exception e) { return false; } + } + + String stooqIndex(String yahoo) { + if ("^GSPC".equals(yahoo)) return "^spx"; + if ("^DJI".equals(yahoo)) return "^dji"; + if ("^IXIC".equals(yahoo)) return "^ndq"; + return null; + } + + /** 腾讯源(国内网络回落) */ + boolean fetchTencent(Quote q) { + try { + String body = httpGet("https://qt.gtimg.cn/q=" + q.tencent); + String tag = "v_" + q.tencent + "=\""; + int i = body.indexOf(tag); if (i < 0) return false; + int s = i + tag.length(), e = body.indexOf('"', s); + String[] f = body.substring(s, e).split("~"); + if (f.length < 39) return false; + applyQuote(q, Double.parseDouble(f[3]), Double.parseDouble(f[4]), + "CNY", "TCT", + Double.parseDouble(f[33]), Double.parseDouble(f[34]), Double.parseDouble(f[36])); + return true; + } catch (Exception ex) { return false; } + } + + void applyQuote(Quote q, double price, double prev, String cur, String exch, + double high, double low, double vol) { + q.priceNum = price; q.prevNum = prev; + q.currency = cur; q.exchange = exch; + q.price = fmt(price); + double d = price - prev; + q.up = d >= 0; + q.change = (d >= 0 ? "+" : "-") + fmt(Math.abs(d)); + q.pct = String.format(Locale.US, "%+.2f%%", prev != 0 ? d / prev * 100 : 0); + if (!Double.isNaN(high)) q.highS = fmt(high); + if (!Double.isNaN(low)) q.lowS = fmt(low); + if (!Double.isNaN(vol)) q.volS = fmtVol(vol); + q.loaded = true; + } + + String fmt(double v) { + if (Math.abs(v) >= 1e6) return String.format(Locale.US, "%.2fM", v / 1e6); + if (Math.abs(v) >= 10000) return String.format(Locale.US, "%.0f", v); + if (Math.abs(v) < 1 && v != 0) return String.format(Locale.US, "%.4f", v); + return String.format(Locale.US, "%.2f", v); + } + + String fmtVol(double v) { + if (v >= 1e9) return String.format(Locale.US, "%.2fB", v / 1e9); + if (v >= 1e6) return String.format(Locale.US, "%.2fM", v / 1e6); + if (v >= 1e3) return String.format(Locale.US, "%.1fK", v / 1e3); + return String.format(Locale.US, "%.0f", v); + } + + /* ══════════ 详情 ══════════ */ + + void openDetail(String yahooSym) { + for (Quote q : quotes) { + if (q.yahoo.equals(yahooSym)) { detail = q; break; } + } + if (detail == null) detail = new Quote(yahooSym, null, yahooSym); + showPage(PAGE_DETAIL); + renderDetail(); + net.submit(new Runnable() { @Override public void run() { fetchDetail(); }}); + } + + void renderDetail() { + if (detail == null) return; + dTitle.setText("DES · " + t("详情", "DETAIL") + " · " + detail.sym); + dName.setText(detail.name + " (" + detail.sym + ")"); + dPrice.setText(detail.price + (detail.currency.isEmpty() ? "" : " " + detail.currency)); + int cl = detail.loaded ? (detail.up ? RED : GREEN) : GREY; + dPrice.setTextColor(cl); + dChange.setText(detail.change + " " + detail.pct); + dChange.setTextColor(cl); + dH.setText(detail.highS); dL.setText(detail.lowS); + dP.setText(Double.isNaN(detail.prevNum) ? "--" : fmt(detail.prevNum)); + dV.setText(detail.volS); + LinearLayout r3 = (LinearLayout) dUpdated.getParent(); + dUpdated.setText((detail.exchange.isEmpty() ? "" : detail.exchange + " · ") + + t("更新 ", "updated ") + hm.format(new Date())); + } + + void fetchDetail() { + try { + String json = httpGet("https://query1.finance.yahoo.com/v8/finance/chart/" + + URLEncoder.encode(detail.yahoo, "UTF-8") + "?range=1d&interval=5m"); + JSONObject result = new JSONObject(json) + .getJSONObject("chart").getJSONArray("result").getJSONObject(0); + JSONObject meta = result.getJSONObject("meta"); + double price = meta.optDouble("regularMarketPrice", Double.NaN); + double prev = meta.optDouble("chartPreviousClose", + meta.optDouble("previousClose", Double.NaN)); + applyQuote(detail, price, prev, meta.optString("currency", ""), + meta.optString("exchangeName", ""), + meta.optDouble("regularMarketDayHigh", Double.NaN), + meta.optDouble("regularMarketDayLow", Double.NaN), + meta.optDouble("regularMarketVolume", Double.NaN)); + + final List pts = new ArrayList<>(); + JSONObject quote = result.getJSONObject("indicators").getJSONArray("quote").getJSONObject(0); + org.json.JSONArray ts = result.optJSONArray("timestamp"); + org.json.JSONArray cl = quote.optJSONArray("close"); + double lastKnown = Double.NaN; + if (ts != null && cl != null) { + for (int i = 0; i < ts.length() && i < cl.length(); i++) { + double v = cl.optDouble(i, Double.NaN); + if (Double.isNaN(v)) continue; + MPoint mp = new MPoint(); + mp.t = ts.getLong(i); + mp.price = v; + pts.add(mp); + lastKnown = v; + } + } + final double fprev = Double.isNaN(prev) ? lastKnown : prev; + ui.post(new Runnable() { @Override public void run() { + chart.setData(pts, fprev); + renderDetail(); + quoteAdapter.notifyDataSetChanged(); + }}); + } catch (Exception ignored) { } + fetchRss("https://finance.yahoo.com/rss/2.0/headline?s=" + detail.yahoo, detailNews, detailNewsAdapter); + } + + /* ══════════ 搜索 ══════════ */ + + void doSearch(final String q) { + net.submit(new Runnable() { @Override public void run() { + try { + String json = httpGet("https://query1.finance.yahoo.com/v1/finance/search?q=" + + URLEncoder.encode(q, "UTF-8") + ""esCount=15&newsCount=0"); + final List out = new ArrayList<>(); + org.json.JSONArray arr = new JSONObject(json).optJSONArray("quotes"); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + JSONObject o = arr.getJSONObject(i); + SearchHit h = new SearchHit(); + h.symbol = o.optString("symbol", ""); + h.name = o.optString("shortname", o.optString("longname", "")); + h.exchange = o.optString("exchDisp", o.optString("exchange", "")); + h.type = o.optString("quoteType", ""); + if (h.symbol.isEmpty()) continue; + out.add(h); + } + } + ui.post(new Runnable() { @Override public void run() { + hits.clear(); hits.addAll(out); + hitAdapter.notifyDataSetChanged(); + }}); + } catch (Exception ignored) { } + }}); + } + + void addToWatch(SearchHit h) { + for (Quote q : quotes) + if (q.yahoo.equals(h.symbol)) { + Toast.makeText(this, t("已在自选中", "Already in watchlist"), Toast.LENGTH_SHORT).show(); + return; + } + Quote q = new Quote(h.symbol, null, h.name.isEmpty() ? h.symbol : h.name); + quotes.add(q); + saveWatchlist(); + quoteAdapter.notifyDataSetChanged(); + Toast.makeText(this, t("已添加:", "Added: ") + h.symbol, Toast.LENGTH_SHORT).show(); + refreshQuotes(); + } + + void confirmRemove(final Quote q) { + new AlertDialog.Builder(this) + .setTitle(t("移除自选", "Remove")) + .setMessage((q.name.isEmpty() ? q.sym : q.name) + " (" + q.sym + ")") + .setPositiveButton(t("移除", "Remove"), new android.content.DialogInterface.OnClickListener() { + @Override public void onClick(android.content.DialogInterface d, int w) { + quotes.remove(q); + saveWatchlist(); + quoteAdapter.notifyDataSetChanged(); + }}) + .setNegativeButton(t("取消", "Cancel"), null) + .show(); + } + + /* ══════════ 资讯 RSS ══════════ */ + + void fetchNews() { + fetchRss("https://finance.yahoo.com/news/rssindex", news, newsAdapter); + } + + void fetchRss(final String feed, final List sink, final NewsAdapter adapter) { + net.submit(new Runnable() { @Override public void run() { + try { + String xml = httpGet(feed); + final List out = new ArrayList<>(); + int i = 0; + while (out.size() < 30) { + int s = xml.indexOf("", i); + if (s < 0) break; + int e = xml.indexOf("", s); + if (e < 0) break; + String seg = xml.substring(s, e); + NewsItem n = new NewsItem(); + n.title = tag(seg, "title"); + n.time = tag(seg, "pubDate"); + n.link = tag(seg, "link"); + if (!n.title.isEmpty()) out.add(n); + i = e + 7; + } + ui.post(new Runnable() { @Override public void run() { + if (out.isEmpty()) return; + sink.clear(); sink.addAll(out); + adapter.notifyDataSetChanged(); + }}); + } catch (Exception ignored) { } + }}); + } + + static String tag(String xml, String name) { + int s = xml.indexOf("<" + name + ">"); + if (s < 0) return ""; + s += name.length() + 2; + int e = xml.indexOf("", s); + if (e < 0) return ""; + String v = xml.substring(s, e); + v = v.replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'").replace("'", "'"); + return v; + } + + /* ══════════ 适配器 ══════════ */ + + class QuoteAdapter extends BaseAdapter { + @Override public int getCount() { return quotes.size(); } + @Override public Object getItem(int pos) { return quotes.get(pos); } + @Override public long getItemId(int pos) { return pos; } + + @Override public View getView(int pos, View cv, ViewGroup parent) { + LinearLayout row; + if (cv instanceof LinearLayout) row = (LinearLayout) cv; + else { + row = new LinearLayout(MainActivity.this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(dp(12), dp(7), dp(12), dp(7)); + row.addView(nameCol(), rowLP(2.4f)); + TextView p = mono("", 14, WHITE, Typeface.BOLD); p.setGravity(Gravity.RIGHT); + row.addView(p, rightLP(1.2f)); + TextView c = mono("", 13, WHITE, Typeface.NORMAL); c.setGravity(Gravity.RIGHT); + row.addView(c, rightLP(1.1f)); + TextView g = mono("", 13, WHITE, Typeface.BOLD); g.setGravity(Gravity.RIGHT); + row.addView(g, rightLP(1.1f)); + } + final Quote q = quotes.get(pos); + LinearLayout nc = (LinearLayout) row.getChildAt(0); + TextView nm = (TextView) nc.getChildAt(0); + TextView cd = (TextView) nc.getChildAt(1); + nm.setText(q.name.isEmpty() ? q.sym : q.name); + cd.setText(q.sym + (q.currency.isEmpty() ? "" : " · " + q.currency)); + nm.setTextColor(q.loaded ? WHITE : GREY); + + int cl = q.loaded ? (q.up ? RED : GREEN) : GREY; + TextView p = (TextView) row.getChildAt(1); + TextView c = (TextView) row.getChildAt(2); + TextView g = (TextView) row.getChildAt(3); + p.setText(q.price); p.setTextColor(cl); + c.setText(q.change); c.setTextColor(cl); + g.setText(q.pct); g.setTextColor(cl); + row.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + openDetail(q.yahoo); + }}); + row.setOnLongClickListener(new View.OnLongClickListener() { + @Override public boolean onLongClick(View v) { confirmRemove(q); return true; } + }); + return row; + } + + LinearLayout nameCol() { + LinearLayout col = new LinearLayout(MainActivity.this); + col.setOrientation(LinearLayout.VERTICAL); + col.addView(mono("", 14, WHITE, Typeface.BOLD), wrapWrap()); + col.addView(mono("", 9, GREY, Typeface.NORMAL), wrapWrap()); + return col; + } + } + + class NewsAdapter extends BaseAdapter { + final List src; + NewsAdapter(List src) { this.src = src; } + @Override public int getCount() { return src.size(); } + @Override public Object getItem(int pos) { return src.get(pos); } + @Override public long getItemId(int pos) { return pos; } + + @Override public View getView(int pos, View cv, ViewGroup parent) { + LinearLayout row; + if (cv instanceof LinearLayout) row = (LinearLayout) cv; + else { + row = new LinearLayout(MainActivity.this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(dp(12), dp(8), dp(12), dp(8)); + TextView tt = mono("", 10, ORANGE, Typeface.BOLD); + row.addView(tt, rowLP(0.55f)); + TextView x = mono("", 12, WHITE, Typeface.NORMAL); + row.addView(x, rowLP(4.2f)); + } + NewsItem n = src.get(pos); + String time = ""; + if (n.time.length() >= 5) { + int c = n.time.indexOf(':'); + if (c >= 3) time = n.time.substring(c - 2, c + 3); + } + ((TextView) row.getChildAt(0)).setText(time); + ((TextView) row.getChildAt(1)).setText(n.title); + return row; + } + } + + class HitAdapter extends BaseAdapter { + @Override public int getCount() { return hits.size(); } + @Override public Object getItem(int pos) { return hits.get(pos); } + @Override public long getItemId(int pos) { return pos; } + + @Override public View getView(int pos, View cv, ViewGroup parent) { + LinearLayout row; + if (cv instanceof LinearLayout) row = (LinearLayout) cv; + else { + row = new LinearLayout(MainActivity.this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(dp(12), dp(9), dp(12), dp(9)); + TextView s = mono("", 14, ORANGE, Typeface.BOLD); + row.addView(s, rowLP(1.3f)); + LinearLayout col = new LinearLayout(MainActivity.this); + col.setOrientation(LinearLayout.VERTICAL); + col.addView(mono("", 12, WHITE, Typeface.NORMAL), wrapWrap()); + col.addView(mono("", 9, GREY, Typeface.NORMAL), wrapWrap()); + row.addView(col, rowLP(2.8f)); + TextView add = mono("[+]", 14, GREEN, Typeface.BOLD); + add.setGravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL); + row.addView(add, rightLP(0.5f)); + } + final SearchHit h = hits.get(pos); + ((TextView) row.getChildAt(0)).setText(h.symbol); + LinearLayout col = (LinearLayout) row.getChildAt(1); + ((TextView) col.getChildAt(0)).setText(h.name); + ((TextView) col.getChildAt(1)).setText(h.type + " · " + h.exchange); + row.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + addToWatch(h); + openDetail(h.symbol); + }}); + row.setOnLongClickListener(new View.OnLongClickListener() { + @Override public boolean onLongClick(View v) { addToWatch(h); return true; } + }); + return row; + } + } + + /* ══════════ 分时图 ══════════ */ + + class MinuteChart extends View { + List data = new ArrayList<>(); + double prev = Double.NaN; + final Paint pLine = new Paint(), pFill = new Paint(), pGrid = new Paint(), + pBase = new Paint(), pText = new Paint(), pLast = new Paint(); + double lo = 0, hi = 1; + double loT = 0, hiT = 1; + + MinuteChart(Context c) { + super(c); + pLine.setColor(ORANGE); pLine.setStyle(Paint.Style.STROKE); pLine.setStrokeWidth(dpx(1.5f)); + pFill.setColor(0x33FF9900); pFill.setStyle(Paint.Style.FILL); + pGrid.setColor(LINE); pGrid.setStrokeWidth(1); + pBase.setColor(AMBER); pBase.setStyle(Paint.Style.STROKE); + pBase.setPathEffect(new android.graphics.DashPathEffect(new float[]{6, 6}, 0)); + pText.setColor(GREY); pText.setTextSize(dp(9)); pText.setTypeface(Typeface.MONOSPACE); + pLast.setTextSize(dp(10)); + pLast.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)); + } + + void setData(List pts, double prevClose) { + data = pts; prev = prevClose; + if (pts.isEmpty()) { invalidate(); return; } + lo = Double.MAX_VALUE; hi = -Double.MAX_VALUE; + for (MPoint p : pts) { lo = Math.min(lo, p.price); hi = Math.max(hi, p.price); } + if (!Double.isNaN(prevClose)) { lo = Math.min(lo, prevClose); hi = Math.max(hi, prevClose); } + if (hi - lo < 1e-9) { hi += 1; lo -= 1; } + double pad = (hi - lo) * 0.08; hi += pad; lo -= pad; + loT = pts.get(0).t; hiT = pts.get(pts.size() - 1).t; + if (hiT <= loT) hiT = loT + 1; + invalidate(); + } + + @Override protected void onDraw(Canvas cv) { + int w = getWidth(), h = getHeight(); + int pl = dp(8), pr = dp(60), pt = dp(14), pb = dp(16); + cv.drawColor(BG); + for (int i = 0; i <= 4; i++) { + float y = pt + (h - pt - pb) * i / 4f; + cv.drawLine(pl, y, w - pr, y, pGrid); + double v = hi - (hi - lo) * i / 4.0; + cv.drawText(fmt(v), w - pr + dp(4), y + dp(3), pText); + } + if (data.isEmpty()) { + cv.drawText(t("暂无分时数据", "No intraday data"), pl, h / 2f, pText); + return; + } + if (!Double.isNaN(prev)) { + float y = (float) (pt + (h - pt - pb) * (1 - (prev - lo) / (hi - lo))); + cv.drawLine(pl, y, w - pr, y, pBase); + } + Path line = new Path(), fill = new Path(); + boolean first = true; + for (MPoint p : data) { + float x = (float) (pl + (w - pl - pr) * (p.t - loT) / (hiT - loT)); + float y = (float) (pt + (h - pt - pb) * (1 - (p.price - lo) / (hi - lo))); + if (first) { line.moveTo(x, y); fill.moveTo(x, h - pb); fill.lineTo(x, y); first = false; } + else { line.lineTo(x, y); fill.lineTo(x, y); } + } + MPoint lastP = data.get(data.size() - 1); + fill.lineTo(w - pr, h - pb); fill.close(); + cv.drawPath(fill, pFill); + cv.drawPath(line, pLine); + float lx = (float) (pl + (w - pl - pr) * (lastP.t - loT) / (hiT - loT)); + float ly = (float) (pt + (h - pt - pb) * (1 - (lastP.price - lo) / (hi - lo))); + pLast.setColor(lastP.price >= prev ? RED : GREEN); + cv.drawCircle(lx, ly, dpx(2.5f), pLast); + String ls = fmt(lastP.price); + cv.drawText(ls, Math.min(lx + dp(4), w - pLast.measureText(ls) - dp(2)), + Math.max(ly - dp(6), pt + dp(8)), pLast); + SimpleDateFormat tf = new SimpleDateFormat("HH:mm", Locale.US); + cv.drawText(tf.format(new Date((long) (loT * 1000L))), pl, h - dp(4), pText); + String rs = tf.format(new Date((long) (hiT * 1000L))); + cv.drawText(rs, w - pr - pText.measureText(rs), h - dp(4), pText); + } + } + + @Override protected void onDestroy() { + super.onDestroy(); + ui.removeCallbacksAndMessages(null); + net.shutdownNow(); + } +}