forked from
lass.pds.lassul.us/superconfig
This repository has no description
10 kB
270 lines
1package us.lassul.assist;
2
3import android.content.Context;
4import android.content.SharedPreferences;
5import android.graphics.Color;
6import android.graphics.Typeface;
7import android.text.SpannableStringBuilder;
8import android.text.Spanned;
9import android.text.style.ForegroundColorSpan;
10import android.text.style.StyleSpan;
11import android.util.AtomicFile;
12import android.util.Log;
13import java.io.File;
14import java.io.FileOutputStream;
15import java.io.IOException;
16import java.nio.charset.StandardCharsets;
17import java.security.SecureRandom;
18import java.text.SimpleDateFormat;
19import java.util.ArrayList;
20import java.util.Arrays;
21import java.util.Collections;
22import java.util.Comparator;
23import java.util.Date;
24import java.util.List;
25import java.util.Locale;
26import java.util.TimeZone;
27import org.json.JSONArray;
28import org.json.JSONException;
29import org.json.JSONObject;
30
31/**
32 * A conversation with Hermes across calls, like the web page's localStorage state: the
33 * transcript, how much of it went to Hermes already, and Hermes's session id. Every conversation
34 * is a file in conversations/; the current one continues with the next call unless the last call
35 * is over an hour old or "New conversation" was chosen.
36 */
37final class Conversation {
38 static final int ACCENT = Color.parseColor("#4fb477");
39 static final int LIVE = Color.parseColor("#d9534f");
40 static final int DIM = Color.parseColor("#8a8a8a");
41 private static final long NEW_AFTER_MS = 60 * 60 * 1000;
42 private static final String PREFS = "conversation";
43
44 static final class Line {
45 final String speaker;
46 String text;
47
48 Line(String speaker, String text) {
49 this.speaker = speaker;
50 this.text = text;
51 }
52 }
53
54 final String hermesSession;
55 final long started;
56 final List<Line> lines = new ArrayList<>();
57 // Lines before this index went to Hermes (delegation or hang-up) already.
58 int delegated;
59 double billedSeconds;
60 private final Context context;
61
62 private Conversation(Context context, String hermesSession, long started) {
63 this.context = context.getApplicationContext();
64 this.hermesSession = hermesSession;
65 this.started = started;
66 }
67
68 /** The conversation the next call continues. */
69 static Conversation load(Context context) {
70 SharedPreferences prefs = prefs(context);
71 String current = prefs.getString("current", null);
72 if (current != null
73 && System.currentTimeMillis() - prefs.getLong("last_used", 0) < NEW_AFTER_MS) {
74 Conversation saved = read(context, current);
75 if (saved != null) {
76 return saved;
77 }
78 }
79 return new Conversation(context, newSessionId(), System.currentTimeMillis());
80 }
81
82 /** The conversation shown on the main screen: the current one, however old; null if none. */
83 static Conversation current(Context context) {
84 String current = prefs(context).getString("current", null);
85 return current == null ? null : read(context, current);
86 }
87
88 /** The next call starts a new conversation (and Hermes session); the old one stays listed. */
89 static void startNew(Context context) {
90 prefs(context).edit().remove("current").apply();
91 Live.changed();
92 }
93
94 /** All conversations, newest first. */
95 static List<Conversation> all(Context context) {
96 File[] files = directory(context).listFiles();
97 List<Conversation> out = new ArrayList<>();
98 for (File file : files == null ? new File[0] : files) {
99 String name = file.getName();
100 if (name.endsWith(".json")) {
101 Conversation conversation =
102 read(context, name.substring(0, name.length() - ".json".length()));
103 if (conversation != null) {
104 out.add(conversation);
105 }
106 }
107 }
108 Collections.sort(out, new Comparator<Conversation>() {
109 @Override
110 public int compare(Conversation a, Conversation b) {
111 return Long.compare(b.started, a.started);
112 }
113 });
114 return out;
115 }
116
117 static Conversation read(Context context, String hermesSession) {
118 File file = file(context, hermesSession);
119 if (!file.exists()) {
120 return null;
121 }
122 try {
123 JSONObject state = new JSONObject(
124 new String(new AtomicFile(file).readFully(), StandardCharsets.UTF_8));
125 Conversation conversation = new Conversation(context, hermesSession,
126 state.optLong("started", file.lastModified()));
127 JSONArray lines = state.getJSONArray("lines");
128 for (int i = 0; i < lines.length(); i++) {
129 JSONObject line = lines.getJSONObject(i);
130 conversation.lines.add(new Line(line.getString("speaker"), line.getString("text")));
131 }
132 conversation.delegated = state.optInt("delegated", conversation.lines.size());
133 conversation.billedSeconds = state.optDouble("billed_seconds", 0);
134 return conversation;
135 } catch (IOException | JSONException error) {
136 Log.w(CallService.TAG, "unreadable conversation " + hermesSession, error);
137 return null;
138 }
139 }
140
141 /** voice-YYYYMMDD-<hex>, like the web page's. */
142 private static String newSessionId() {
143 SimpleDateFormat day = new SimpleDateFormat("yyyyMMdd", Locale.ROOT);
144 day.setTimeZone(TimeZone.getTimeZone("UTC"));
145 String random = Integer.toHexString(new SecureRandom().nextInt());
146 return "voice-" + day.format(new Date()) + "-" + random;
147 }
148
149 void save() {
150 AtomicFile file = new AtomicFile(file(context, hermesSession));
151 FileOutputStream out = null;
152 try {
153 JSONObject state = new JSONObject()
154 .put("started", started)
155 .put("lines", toJson(lines))
156 .put("delegated", delegated)
157 .put("billed_seconds", billedSeconds);
158 directory(context).mkdirs();
159 out = file.startWrite();
160 out.write(state.toString().getBytes(StandardCharsets.UTF_8));
161 file.finishWrite(out);
162 } catch (IOException | JSONException error) {
163 file.failWrite(out);
164 Log.w(CallService.TAG, "conversation not saved", error);
165 return;
166 }
167 prefs(context).edit()
168 .putString("current", hermesSession)
169 .putLong("last_used", System.currentTimeMillis())
170 .apply();
171 Live.changed();
172 }
173
174 void add(String speaker, String text) {
175 lines.add(new Line(speaker, text));
176 save();
177 }
178
179 /** Lines since the last hand-over to Hermes, of the given speakers. */
180 List<Line> undelegated(String... speakers) {
181 List<Line> out = new ArrayList<>();
182 List<String> wanted = Arrays.asList(speakers);
183 for (Line line : lines.subList(Math.min(delegated, lines.size()), lines.size())) {
184 if (wanted.contains(line.speaker)) {
185 out.add(line);
186 }
187 }
188 return out;
189 }
190
191 /** The first thing the user said, for the session list. */
192 String title() {
193 for (Line line : lines) {
194 if (line.speaker.equals("user") && !line.text.trim().isEmpty()) {
195 return line.text.trim();
196 }
197 }
198 return "(nothing said)";
199 }
200
201 /** The transcript as the web page shows it: colored speaker labels, Hermes dimmed. */
202 CharSequence render() {
203 SpannableStringBuilder out = new SpannableStringBuilder();
204 for (Line line : lines) {
205 String text = line.text.trim();
206 if (text.isEmpty()) {
207 continue;
208 }
209 if (out.length() > 0) {
210 out.append("\n\n");
211 }
212 String label;
213 int labelColor;
214 int textColor = 0;
215 switch (line.speaker) {
216 case "user":
217 label = "you: ";
218 labelColor = ACCENT;
219 break;
220 case "assistant":
221 label = "voice: ";
222 labelColor = DIM;
223 break;
224 case "error":
225 label = "error: ";
226 labelColor = LIVE;
227 textColor = LIVE;
228 break;
229 default:
230 label = line.speaker + ": ";
231 labelColor = ACCENT;
232 textColor = DIM;
233 break;
234 }
235 int start = out.length();
236 out.append(label);
237 out.setSpan(new ForegroundColorSpan(labelColor), start, out.length(),
238 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
239 out.setSpan(new StyleSpan(Typeface.BOLD), start, out.length(),
240 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
241 int body = out.length();
242 out.append(text);
243 if (textColor != 0) {
244 out.setSpan(new ForegroundColorSpan(textColor), body, out.length(),
245 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
246 }
247 }
248 return out;
249 }
250
251 static JSONArray toJson(List<Line> lines) throws JSONException {
252 JSONArray out = new JSONArray();
253 for (Line line : lines) {
254 out.put(new JSONObject().put("speaker", line.speaker).put("text", line.text));
255 }
256 return out;
257 }
258
259 private static SharedPreferences prefs(Context context) {
260 return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
261 }
262
263 private static File directory(Context context) {
264 return new File(context.getFilesDir(), "conversations");
265 }
266
267 private static File file(Context context, String hermesSession) {
268 return new File(directory(context), hermesSession + ".json");
269 }
270}