77
88import androidx .annotation .Nullable ;
99
10+ import java .io .BufferedReader ;
11+ import java .io .File ;
1012import java .io .IOException ;
11- import java .io .UncheckedIOException ;
13+ import java .io .InputStream ;
14+ import java .io .InputStreamReader ;
15+ import java .io .RandomAccessFile ;
16+ import java .io .Reader ;
1217import java .net .InetAddress ;
1318import java .net .UnknownHostException ;
14- import java .nio .file .Files ;
15- import java .nio .file .Path ;
19+ import java .nio .charset .StandardCharsets ;
1620import java .util .Arrays ;
1721import java .util .Objects ;
22+ import java .util .concurrent .atomic .AtomicBoolean ;
1823import java .util .concurrent .atomic .AtomicLong ;
19- import java .util .concurrent .atomic .AtomicReference ;
2024
2125/**
2226 * <p>An API to handle Terracotta Android.</p>
4347 * or Terracotta would stuck and EasyTier cannot submit a new VpnService Request.</p>
4448 *
4549 * <p>The VpnServiceRequest must be fulfilled in 30 seconds, or it will be considered as timeout.</p>
50+ *
51+ * <h1>Panic</h1>
52+ *
53+ * <p>A RuntimeException will be thrown if an error occured in native level.</p>
4654 */
4755public final class TerracottaAndroidAPI {
4856 /**
@@ -68,9 +76,8 @@ public interface VpnServiceRequest {
6876 * @param builder A pre-configured VpnService builder.
6977 * @return The established Vpn Connection. Developers must close this file descriptor after EasyTier exits.
7078 * @throws RuntimeException if {@link VpnService.Builder#establish()} returns null.
71- *
7279 * @implNote Developers is able to configure the builder before passing it into Terracotta
73- * to fully-custom the connection.
80+ * to fully-custom the connection.
7481 */
7582 ParcelFileDescriptor startVpnService (VpnService .Builder builder );
7683
@@ -80,16 +87,66 @@ public interface VpnServiceRequest {
8087 void reject ();
8188 }
8289
90+ /**
91+ * <p>Metadata of Terracotta Android</p>
92+ */
93+ public static final class Metadata {
94+ private final String terracottaVersion ;
95+
96+ private final long terracottaCompileTime ;
97+
98+ private final String easyTierVersion ;
99+
100+ private Metadata (String terracottaVersion , long terracottaCompileTime , String easyTierVersion ) {
101+ this .terracottaVersion = terracottaVersion ;
102+ this .terracottaCompileTime = terracottaCompileTime ;
103+ this .easyTierVersion = easyTierVersion ;
104+ }
105+
106+ /**
107+ * @return Terracotta Android version.
108+ */
109+ public String getTerracottaVersion () {
110+ return terracottaVersion ;
111+ }
112+
113+ /**
114+ * @return Terracotta Android compile timestamp. The format is identical with {@link System#currentTimeMillis()}.
115+ */
116+ public long getTerracottaCompileTime () {
117+ return terracottaCompileTime ;
118+ }
119+
120+ /**
121+ * @return EasyTier version.
122+ */
123+ public String getEasyTierVersion () {
124+ return easyTierVersion ;
125+ }
126+ }
127+
83128 static {
84129 System .loadLibrary ("terracotta" );
85130 }
86131
87132 private static volatile VpnServiceRequest pendingRequest = null ;
88133
89- private static final AtomicReference <VpnServiceCallback > VPN_SERVICE_CALLBACK = new AtomicReference <>(null );
134+ private static final class RuntimeContext {
135+ private final VpnServiceCallback vpnServiceCallback ;
136+
137+ private final RandomAccessFile logging ;
138+
139+ public RuntimeContext (VpnServiceCallback vpnServiceCallback , RandomAccessFile logging ) {
140+ this .vpnServiceCallback = vpnServiceCallback ;
141+ this .logging = logging ;
142+ }
143+ }
144+
145+ private static volatile RuntimeContext runtimeContext = null ;
90146
91147 /**
92148 * <p>Get current pending VpnService Request.</p>
149+ *
93150 * @return The pending VpnService Request.
94151 * @throws IllegalStateException if no pending VpnService Request exists.
95152 */
@@ -107,18 +164,43 @@ public static VpnServiceRequest getPendingVpnServiceRequest() {
107164 * @param context An Android context object.
108165 * @param callback A callback to handle VpnService for EasyTier. See {@link VpnServiceCallback} for more information.
109166 */
110- public static void initialize (Context context , VpnServiceCallback callback ) {
111- if (VPN_SERVICE_CALLBACK .compareAndSet (null , callback )) {
112- Path base = context .getFilesDir ().toPath ().resolve ("net.burningtnt.terracotta/rs" );
113- try {
114- Files .createDirectories (base );
115- } catch (IOException e ) {
116- throw new UncheckedIOException (e );
117- }
118- start0 (base .toString ());
119- } else {
167+ public static synchronized Metadata initialize (Context context , VpnServiceCallback callback ) {
168+ Objects .requireNonNull (context , "context" );
169+ Objects .requireNonNull (callback , "callback" );
170+
171+ if (runtimeContext != null ) {
120172 throw new IllegalStateException ("Terracotta Android has already started." );
121173 }
174+
175+ File root = new File (context .getFilesDir (), "net.burningtnt.terracotta" );
176+
177+ File base = new File (root , "rs" );
178+ base .mkdirs ();
179+ if (!base .isDirectory ()) {
180+ throw new RuntimeException ("Cannot create net.burningtnt.terracotta/rs directory." );
181+ }
182+
183+ RandomAccessFile logging ;
184+ int fd ;
185+ try {
186+ logging = new RandomAccessFile (new File (root , "application.log" ), "rw" );
187+ fd = ParcelFileDescriptor .dup (logging .getFD ()).detachFd ();
188+ } catch (IOException e ) {
189+ throw new RuntimeException (e );
190+ }
191+
192+ int code = start0 (base .getAbsolutePath (), fd );
193+ if (code != 0 ) {
194+ throw new RuntimeException ("Cannot start Terracotta Android: " + code );
195+ }
196+
197+ runtimeContext = new RuntimeContext (callback , logging );
198+
199+ String [] parts = getMetadata0 ().split ("\0 " , 4 );
200+ if (parts .length != 3 ) {
201+ throw new AssertionError ("Should NOT be here." );
202+ }
203+ return new Metadata (parts [0 ], Long .parseLong (parts [1 ]), parts [2 ]);
122204 }
123205
124206 /**
@@ -177,10 +259,130 @@ public static boolean setGuesting(String room, @Nullable String player) {
177259 return setGuesting0 (room , player );
178260 }
179261
262+ /**
263+ * Room types supported by Terracotta Android
264+ */
265+ public enum RoomType {
266+ TERRACOTTA_LEGACY , PCL2CE , SCAFFOLDING
267+ }
268+
269+ /**
270+ * <p>Parse the given room code.</p>
271+ *
272+ * @param room A room code.
273+ * @return The type of the room, or null if invalid.
274+ * @throws NullPointerException if room is null
275+ * @implNote Though this method is fast enough to be invoked in UI thread,
276+ * dispatch all invocation to a separated worker is better in order to
277+ * prevent potential UI freezing issues.
278+ */
279+ public static RoomType parseRoomCode (String room ) {
280+ Objects .requireNonNull (room , "room" );
281+
282+ assertStarted ();
283+ switch (verifyRoomCode0 (room )) {
284+ case -1 :
285+ return null ;
286+ case 1 :
287+ return RoomType .TERRACOTTA_LEGACY ;
288+ case 2 :
289+ return RoomType .PCL2CE ;
290+ case 3 :
291+ return RoomType .SCAFFOLDING ;
292+ default :
293+ throw new AssertionError ("Should NOT be here." );
294+ }
295+ }
296+
297+ /**
298+ * <p>Collect logs of Terracotta Android.</p>
299+ *
300+ * <p>Developers must immediately copy all data out of the returned reader and close it.
301+ * Otherwise all methods, except 'parseRoomCode', will be blocked.</p>
302+ *
303+ * @return A reader containing logs.
304+ * @throws RuntimeException if logging export is unsupported.
305+ */
306+ public static Reader collectLogs () throws IOException {
307+ assertStarted ();
308+
309+ long ptr = prepareExportLogs0 ();
310+ if (ptr == 0 ) {
311+ throw new RuntimeException ("Cannot export logs from Terracotta Android." );
312+ }
313+
314+ RandomAccessFile file = runtimeContext .logging ;
315+ file .seek (0 );
316+
317+ return new BufferedReader (new InputStreamReader (new InputStream () {
318+ private final AtomicBoolean closed = new AtomicBoolean (false );
319+
320+ @ Override
321+ public int read () throws IOException {
322+ assertOpen ();
323+ return file .read ();
324+ }
325+
326+ @ Override
327+ public int read (byte [] b ) throws IOException {
328+ assertOpen ();
329+ return file .read (b );
330+ }
331+
332+ @ Override
333+ public int read (byte [] b , int off , int len ) throws IOException {
334+ assertOpen ();
335+ return file .read (b , off , len );
336+ }
337+
338+ @ Override
339+ public int available () throws IOException {
340+ assertOpen ();
341+ return Math .toIntExact (file .length () - file .getFilePointer ());
342+ }
343+
344+ @ Override
345+ public void close () throws IOException {
346+ super .close ();
347+ cleanup ();
348+ }
349+
350+ @ Override
351+ protected void finalize () throws Throwable {
352+ super .finalize ();
353+ cleanup ();
354+ }
355+
356+ private void assertOpen () throws IOException {
357+ if (closed .get ()) {
358+ throw new IOException ("Stream has already been closed" );
359+ }
360+ }
361+
362+ private void cleanup () {
363+ if (closed .compareAndSet (false , true )) {
364+ finishExportLogs0 (ptr );
365+ }
366+ }
367+ }, StandardCharsets .UTF_8 ));
368+ }
369+
370+ @ Deprecated
371+ public static void panic () {
372+ panic0 ();
373+
374+ throw new AssertionError ("Should NOT be here: A RuntimeException should be thrown in panic0" );
375+ }
376+
180377 private static final long FD_PENDING = ((long ) Integer .MAX_VALUE ) + 1 ;
181378 private static final long FD_REJECT = FD_PENDING + 1 ;
182-
379+ ;
380+ @ SuppressWarnings ("unused" ) // Native callback
183381 private static int onVpnServiceStateChanged (byte ip1 , byte ip2 , byte ip3 , byte ip4 , short network_length , String cidr ) throws UnknownHostException {
382+ if (pendingRequest != null ) {
383+ throw new AssertionError ("Should NOT be here." );
384+ }
385+
184386 AtomicLong fd = new AtomicLong (FD_PENDING );
185387 InetAddress address = InetAddress .getByAddress (new byte []{ip1 , ip2 , ip3 , ip4 });
186388
@@ -206,7 +408,7 @@ public ParcelFileDescriptor startVpnService(VpnService.Builder builder) {
206408 throw new RuntimeException ("Cannot establish a VPN connection." );
207409 }
208410
209- fd .set (connection .detachFd ());
411+ fd .set (connection .getFd ());
210412 return connection ;
211413 }
212414
@@ -216,7 +418,7 @@ public void reject() {
216418 }
217419 };
218420
219- VPN_SERVICE_CALLBACK . get () .onStartVpnService ();
421+ TerracottaAndroidAPI . runtimeContext . vpnServiceCallback .onStartVpnService ();
220422
221423 long timestamp = System .currentTimeMillis ();
222424 while (true ) {
@@ -232,18 +434,22 @@ public void reject() {
232434 throw new IllegalStateException ();
233435 } else {
234436 pendingRequest = null ;
235- return Math .toIntExact (value );
437+
438+ if ((int ) value != value ) {
439+ throw new AssertionError ("Should NOT be here." );
440+ }
441+ return (int ) value ;
236442 }
237443 }
238444 }
239445
240446 private static void assertStarted () {
241- if (VPN_SERVICE_CALLBACK . get () == null ) {
447+ if (runtimeContext == null ) {
242448 throw new IllegalStateException ("Terracotta Android hasn't started yet." );
243449 }
244450 }
245451
246- private static native void start0 (String baseDir );
452+ private static native int start0 (String baseDir , int loggingFD );
247453
248454 private static native String getState0 ();
249455
@@ -252,4 +458,14 @@ private static void assertStarted() {
252458 private static native void setScanning0 (String room , String player );
253459
254460 private static native boolean setGuesting0 (String room , String player );
461+
462+ private static native int verifyRoomCode0 (String room );
463+
464+ private static native String getMetadata0 ();
465+
466+ private static native long prepareExportLogs0 ();
467+
468+ private static native void finishExportLogs0 (long pointer );
469+
470+ private static native void panic0 ();
255471}
0 commit comments