The following was composed as a Usenet post to speculate about the direction of client-side Java.
Subject: Re: Join the movement to get PC OEMs to put Java on Windows XP
Chris Newman <chris@floor51.com> wrote . . .
>
> This is a truly irresistible argument and I'd like to play devil's
> advocate.
-grin- Those rubbery masks smell pretty bad, don't they?
>
> Do we really need the JRE on XP ? Java has morphed into a
> remarkable enterprise server side/web services architecture built on a
> visionary language design with a fantastically rich set of APIs. Surely
> serious desktop software that needs a virtual machine can just install
> one along with it a bit like the way Java Web Start does. (or live
> inside the Web Start container?)
I'd like to share a Vision of the Future. In this future (set around 2010), upper middle class America has reached bandwidth nirvana at home. But a non-trivial segment of low wage earners and rural residents still use dialup connections. Context-aware voice recognition is finally usable. The PC/notebook/tablet is an integral part of business and student life. Wireless LANs exist in most public venues such as trade shows, limosines, college campuses, airports, museums, etc. Nevertheless, they aren't available everywhere, and bandwidth decreases linearly with the number of users. 3G wireless service is available in major cities, but the per-minute price is still too high for a middle class person to browse the Web daily using it.
So what will a user want? He'll obviously want info and communication on demand from wireless devices. But also, he'll want *his* data available in a recognizable format *wherever he is*. For instance, if he saved a URL in his browser at home, he wants to be able to look it up at work. If he started writing/dictating a note to his insurance company but had to interupt the task in order to pick up his kid from karate, he wants the partially composed message to be available on his PDA. And, preferrably, he wants to be able to edit it using a similar user interface. Basically, he wants quick access to a portable place.
At least three complementary strategies exist for creating portable placeshosting, synchronization and portable objects. Most of today's Web applications (such as Yahoo! Notepad) use only hosting. Most of today's PDAs use only synchronization. Javaspaces features portable objects. But as I said, these three strategies are complementary. For instance, PC Anywhere combines hosting and synchronization.
The dominant strategy for portable places will depend largely on evolution of wireless networking and battery technology. If 3G wireless evolves into cheap, ubiquitous, high-bandwidth connectivity, then pure hosting is the simplest option. Unfortunately, I don't expect that in the near future.
Meanwhile, back at the ranch (er, I mean, in the dual-mode minivan), our busy friend with the PDA is having to wait for an hour between the end of his son's karate class and the end of his daughter's soccer (football) practice. How will today's technologies evolve to serve him?
Java technology could allow an app to be written to provide rich, familiar, portable user interfaces for both connected and non-connected platforms. Any application could feature both J2SE and J2ME versions (with excellent code reuse between the two). And the J2ME version could be updated on the PDA as part of synchronization. Application state could also be transferred. So when our busy father turns on his PDA, the undo feature works, and the cursor is exactly where he left it.
So what does this have to do with applet support in IE for XP? Well, applets are used on many websites. *If* (and this is a big if) that compatibility issue impells OEMs and users to install a Java 2 JRE, then the user base will increase dramatically. That will have two notable effects. First, Web Start will be a more attractive platform for mainstream client-server application deployment (because the large JRE would already be installed). Second, apps and applets *with recognizable user interfaces* built using free Java development tools will move from academia and opensource onto mainstream PCs.
Both of these trends will decrease the average user's reliance on OS-specific applications.
>
> The further MS stays away from it the better but that's not to say I
> dislike MS - I continue to use the Office suite and Exchange because as
> a software developer myself, for all their annoyances, I like them. It's
> just that the Linux environment is a developer's dream with all the
> tools and the feeling of freedom to innovate that I could want so I
> develop and live under Redhat/Debian with KDE2, Mozilla, NetBeans etc
> only booting into Windows for MS Office. And I develop java code
> directly onto the FAT32 partition under Linux so I can point application
> servers at the same file structure under both operating systems no
> problem. Hopefully one day I'll become good enough to contribute to the
> open-source projects like StarOffice, KDE and Helix Gnome.
As for Microsoft keeping its grubby hands off of Java, I agree.
Thanks for mentioning GNOME. For those of you who don't know, the GNOME desktop is gradually reaching feature parity with the Win32 desktop. And it's available for both Linux and Solaris. GNOME is built using CORBA for component integration. CORBA is a language-agnostic and OS-agnostic open standard architecture for distributed computing.
The J2EE and J2SE SDKs ship with tools for CORBA integration. It allows Java components and components written in other languages to interoperate as equals. To make an analogy with .NET development, a CORBA object request broker (ORB) provides the interface between Java "managed code" components and native "unmanaged code" components. Unfortunately, (as far as I know) Microsoft has vastly superior tool support for this sort of integration. (I'm curious how the two compare with regard to runtime overhead.)
Perhaps it's time for Sun to reformulate the Pure Java campaign as the Pure Java Platform campaign to emphasize that native apps are welcome to use Java platform library features in order to facilitate incremental migration. This would take a lot of wind out of the language freedom rhetoric surrounding .NET. As you say, Linux provides a great environment for developers. Augmenting it with Java "managed code" helps it maintain feature parity with .NET tools.
>
> Anyhow, if I was setting up a server I'd install the latest JDK straight
> from java.sun.com with all the extra jars like xalan/xerces and so on,
> no way using the default installation. So the only thing that is really
> affected is web browser applets under XP. This is not great but then how
> important are applets? (though it will be extremely annoying not to be
> able to use them) With the onset of broadband, internet-enabled TV and
> technology like the Java Web Start, do we really need to be so
> InternetExplorer-centric ?
Digging a channel is often the easiest way to divert a river.
>
> I'd put my money on the proliferation of J2ME in all kinds of mobile
> devices and hope that TV set-top boxes built on Linux would provide
> Java-based APIs for development.
>
> Just ideas :)
>
I see a similar optimistic scenario. Even if applet compatibility doesn't drive Java 2 onto PCs, mobile devices could. Do Japanese Java-enabled mobile phone/PDAs ship with Java technology-based PC synchronization? Or is all state centrally managed (using Sun hardware) and served to PCs through a Web application?
More ideas concerning client-side Java can be found in the following forums. I post as "walpj".
This request for enhancement explores incremental upgrade strategies.
http://developer.java.sun.com/developer/bugParade/bugs/4267080.html
This forum topic explores application management and resource management. It contains my response to bugid 4466510.
http://forum.java.sun.com/thread.jsp?forum=25&thread=156163
walpj at 2007-6-29 10:46:48 >

I took a look at the 1.3.0 source to find out what handles security for applets. Apparently sun.awt.SunToolkit.createNewAppContext() loads the applet class and gives it a sun.awt.AppContext. An AppContext bundles an AWT EventQueue and a ThreadGroup associated with the applet.
So, part of the application instance manager API would expose createNewAppContext(). Also, AppContext must be generalized to group all resources associated with an app, including a ClassLoader.
BTW, another class named AppContext (javax.swing.AppContext) also exists in the 1.3.0 source, but I haven't tried to discover how it's related.
For reference, I've included some source. Note that this source code is covered by Sun's community source license.
http://www.sun.com/software/communitysource/java2/licensing.html
===== sun.awt.SunToolkit.createNewAppContext() method ===
/*
* Create a new AppContext, along with its EventQueue, for a
* new ThreadGroup. Browser code, for example, would use this
* method to create an AppContext & EventQueue for an Applet.
*/
public static AppContext createNewAppContext() {
ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
EventQueue eventQueue;
String eqName = Toolkit.getProperty("AWT.EventQueueClass",
"java.awt.EventQueue");
try {
eventQueue = (EventQueue)Class.forName(eqName).newInstance();
} catch (Exception e) {
System.err.println("Failed loading " + eqName + ": " + e);
eventQueue = new EventQueue();
}
AppContext appContext = new AppContext(threadGroup);
appContext.put(AppContext.EVENT_QUEUE_KEY, eventQueue);
PostEventQueue postEventQueue = new PostEventQueue(eventQueue);
appContext.put(POST_EVENT_QUEUE_KEY, postEventQueue);
return appContext;
}
===== sun.awt.AppContext class ===
/*
* @(#)AppContext.java1.21 00/02/02
*
* Copyright 1998-2000 Sun Microsystems, Inc. All Rights Reserved.
*
* This software is the proprietary information of Sun Microsystems, Inc.
* Use is subject to license terms.
*
*/
package sun.awt;
import java.awt.Frame;
import java.awt.Toolkit;
import java.awt.event.InvocationEvent;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.Hashtable;
import java.util.Enumeration;
import java.awt.datatransfer.Clipboard;
/**
* The AppContext is a table referenced by ThreadGroup which stores
* application service instances. (If you are not writing an application
* service, or don't know what one is, please do not use this class.)
* The AppContext allows applet access to what would otherwise be
* potentially dangerous services, such as the ability to peek at
* EventQueues or change the look-and-feel of a Swing application.<p>
*
* Most application services use a singleton object to provide their
* services, either as a default (such as getSystemEventQueue or
* getDefaultToolkit) or as static methods with class data (System).
* The AppContext works with the former method by extending the concept
* of "default" to be ThreadGroup-specific. Application services
* lookup their singleton in the AppContext.<p>
*
* For example, here we have a Foo service, with its pre-AppContext
* code:<p>
* <code><pre>
*public class Foo {
*private static Foo defaultFoo = new Foo();
*
*public static Foo getDefaultFoo() {
*return defaultFoo;
*}
*
*... Foo service methods
*}</pre></code><p>
*
* The problem with the above is that the Foo service is global in scope,
* so that applets and other untrusted code can execute methods on the
* single, shared Foo instance. The Foo service therefore either needs
* to block its use by untrusted code using a SecurityManager test, or
* restrict its capabilities so that it doesn't matter if untrusted code
* executes it.<p>
*
* Here's the Foo class written to use the AppContext:<p>
* <code><pre>
*public class Foo {
*public static Foo getDefaultFoo() {
*Foo foo = (Foo)AppContext.getAppContext().get(Foo.class);
*if (foo == null) {
*foo = new Foo();
*getAppContext().put(Foo.class, foo);
*}
*return foo;
*}
*
*... Foo service methods
*}</pre></code><p>
*
* Since a separate AppContext can exist for each ThreadGroup, trusted
* and untrusted code have access to different Foo instances. This allows
* untrusted code access to "system-wide" services -- the service remains
* within the AppContext "sandbox". For example, say a malicious applet
* wants to peek all of the key events on the EventQueue to listen for
* passwords; if separate EventQueues are used for each ThreadGroup
* using AppContexts, the only key events that applet will be able to
* listen to are its own. A more reasonable applet request would be to
* change the Swing default look-and-feel; with that default stored in
* an AppContext, the applet's look-and-feel will change without
* disrupting other applets or potentially the browser itself.<p>
*
* Because the AppContext is a facility for safely extending application
* service support to applets, none of its methods may be blocked by a
* a SecurityManager check in a valid Java implementation. Applets may
* therefore safely invoke any of its methods without worry of being
* blocked.
*
* Note: If a SecurityManager is installed which derives from
* sun.awt.AWTSecurityManager, it may override the
* AWTSecurityManager.getAppContext() method to return the proper
* AppContext based on the execution context, in the case where
* the default ThreadGroup-based AppContext indexing would return
* the main "system" AppContext. For example, in an applet situation,
* if a system thread calls into an applet, rather than returning the
* main "system" AppContext (the one corresponding to the system thread),
* an installed AWTSecurityManager may return the applet's AppContext
* based on the execution context.
*
* @author Thomas Ball
* @author Fred Ecks
* @version 1.21 02/02/00
*/
public final class AppContext {
/* Since the contents of an AppContext are unique to each Java
* session, this class should never be serialized. */
/* The key to put()/get() the Java EventQueue into/from the AppContext.
*/
public static final Object EVENT_QUEUE_KEY = new StringBuffer("EventQueue");
/* A map of AppContexts, referenced by ThreadGroup.
*/
private static Hashtable threadGroup2appContext = null;
/* The main "system" AppContext, used by everything not otherwise
contained in another AppContext.
*/
private static AppContext mainAppContext = null;
/*
* The hashtable associated with this AppContext. A private delegate
* is used instead of subclassing Hashtable so as to avoid all of
* Hashtable's potentially risky methods, such as clear(), elements(),
* putAll(), etc. (It probably doesn't need to be final since the
* class is, but I don't trust the compiler to be that smart.)
*/
private final Hashtable table;
private final ThreadGroup threadGroup;
private boolean isDisposed = false; // true if AppContext is disposed
static {
// On the main Thread, we get the ThreadGroup, make a corresponding
// AppContext, and instantiate the Java EventQueue. This way, legacy
// code is unaffected by the move to multiple AppContext ability.
AccessController.doPrivileged(new PrivilegedAction() {
public Object run() {
ThreadGroup currentThreadGroup =
Thread.currentThread().getThreadGroup();
ThreadGroup parentThreadGroup = currentThreadGroup.getParent();
while (parentThreadGroup != null) {
// Find the root ThreadGroup to construct our main AppContext
currentThreadGroup = parentThreadGroup;
parentThreadGroup = currentThreadGroup.getParent();
}
mainAppContext = new AppContext(currentThreadGroup);
numAppContexts = 1;
return mainAppContext;
}
});
}
/*
* The total number of AppContexts, system-wide. This number is
* incremented at the beginning of the constructor, and decremented
* at the end of dispose(). getAppContext() checks to see if this
* number is 1. If so, it returns the sole AppContext without
* checking Thread.currentThread().
*/
private static int numAppContexts;
/**
* Constructor for AppContext. This method is <i>not</i> public,
* nor should it ever be used as such. The proper way to construct
* an AppContext is through the use of SunToolkit.createNewAppContext.
* A ThreadGroup is created for the new AppContext, a Thread is
* created within that ThreadGroup, and that Thread calls
* SunToolkit.createNewAppContext before calling anything else.
* That creates both the new AppContext and its EventQueue.
*
* @paramthreadGroupThe ThreadGroup for the new AppContext
* @seesun.awt.SunToolkit
* @sinceJDK1.2
*/
AppContext(ThreadGroup threadGroup) {
numAppContexts++;
table = new Hashtable(2);
if (threadGroup2appContext == null) {
threadGroup2appContext = new Hashtable(2, 0.2f);
}
this.threadGroup = threadGroup;
threadGroup2appContext.put(threadGroup, this);
}
private static MostRecentThreadAppContext mostRecentThreadAppContext = null;
/**
* Returns the appropriate AppContext for the caller,
* as determined by its ThreadGroup. If the main "system" AppContext
* would be returned and there's an AWTSecurityManager installed, it
* is called to get the proper AppContext based on the execution
* context.
*
* @return the AppContext for the caller.
* @seejava.lang.ThreadGroup
* @sinceJDK1.2
*/
public final static AppContext getAppContext() {
if (numAppContexts == 1)// If there's only one system-wide,
return mainAppContext; // return the main system AppContext.
final Thread currentThread = Thread.currentThread();
AppContext appContext = null;
// Note: this most recent Thread/AppContext caching is thread-hot.
// A simple test using SwingSet found that 96.8% of lookups
// were matched using the most recent Thread/AppContext. By
// instantiating a simple MostRecentThreadAppContext object on
// cache misses, the cache hits can be processed without
// synchronization.
MostRecentThreadAppContext recent = mostRecentThreadAppContext;
if ((recent != null) && (recent.thread == currentThread)) {
appContext = recent.appContext; // Cache hit
} else {
appContext = (AppContext)AccessController.doPrivileged(
new PrivilegedAction() {
public Object run() {
// Get the current ThreadGroup, and look for it and its
// parents in the hash from ThreadGroup to AppContext --
// it should be found, because we use createNewContext()
// when new AppContext objects are created.
ThreadGroup currentThreadGroup = currentThread.getThreadGroup();
ThreadGroup threadGroup = currentThreadGroup;
AppContext context =
(AppContext)threadGroup2appContext.get(threadGroup);
while (context == null) {
threadGroup = threadGroup.getParent();
if (threadGroup == null) {
// If we get here, we're running under a ThreadGroup that
// has no AppContext associated with it. This should never
// happen, because createNewContext() should be used by the
// toolkit to create the ThreadGroup that everything runs
// under.
throw new RuntimeException("Invalid ThreadGroup");
}
context = (AppContext)threadGroup2appContext.get(threadGroup);
}
// In case we did anything in the above while loop, we add
// all the intermediate ThreadGroups to threadGroup2appContext
// so we won't spin again.
for (ThreadGroup tg = currentThreadGroup; tg != threadGroup; tg = tg.getParent()) {
threadGroup2appContext.put(tg, context);
}
// Now we're done, so we cache the latest key/value pair.
// (we do this before checking with any AWTSecurityManager, so if
// this Thread equates with the main AppContext in the cache, it
// still will)
mostRecentThreadAppContext =
new MostRecentThreadAppContext(currentThread, context);
return context;
}
});
}
if (appContext == mainAppContext) {
// Before we return the main "system" AppContext, check to
// see if there's an AWTSecurityManager installed. If so,
// allow it to choose the AppContext to return.
SecurityManager securityManager = System.getSecurityManager();
if ((securityManager != null) &&
(securityManager instanceof AWTSecurityManager)) {
AWTSecurityManager awtSecMgr =
(AWTSecurityManager)securityManager;
AppContext secAppContext = awtSecMgr.getAppContext();
if (secAppContext != null) {
appContext = secAppContext; // Return what we're told
}
}
}
return appContext;
}
private long DISPOSAL_TIMEOUT = 5000; // Default to 5-second timeout
// for disposal of all Frames
// (we wait for this time twice,
// once for dispose(), and once
// to clear the EventQueue).
private long THREAD_INTERRUPT_TIMEOUT = 1000;
// Default to 1-second timeout for all
// interrupted Threads to exit, and another
// 1 second for all stopped Threads to die.
/**
* Disposes of this AppContext, all of its top-level Frames, and
* all Threads and ThreadGroups contained within it.
*
* This method must be called from a Thread which is not contained
* within this AppContext.
*
* @exception IllegalThreadStateException if the current thread is
*contained within this AppContext
* @sinceJDK1.2
*/
public void dispose() throws IllegalThreadStateException {
// Check to be sure that the current Thread isn't in this AppContext
if (this.threadGroup.parentOf(Thread.currentThread().getThreadGroup())) {
throw new IllegalThreadStateException(
"Current Thread is contained within AppContext to be disposed."
);
}
synchronized(this) {
if (this.isDisposed) {
return; // If already disposed, bail.
}
this.isDisposed = true;
}
releaseClipboard();
// First, we post an InvocationEvent to be run on the
// EventDispatchThread which disposes of all top-level Frames
final Object notificationLock = new Object();
Runnable runnable = new Runnable() { public void run() {
Frame [] frames = Frame.getFrames();
for (int i = frames.length - 1; i >= 0; i--) {
frames[i].dispose(); // Dispose of all top-level Frames
}
synchronized(notificationLock) {
notificationLock.notifyAll(); // Notify caller that we're done
}
} };
synchronized(notificationLock) {
SunToolkit.postEvent(this,
new InvocationEvent(Toolkit.getDefaultToolkit(), runnable));
try {
notificationLock.wait(DISPOSAL_TIMEOUT);
} catch (InterruptedException e) { }
}
// Next, we post another InvocationEvent to the end of the
// EventQueue. When it's executed, we know we've executed all
// events in the queue.
runnable = new Runnable() { public void run() {
synchronized(notificationLock) {
notificationLock.notifyAll(); // Notify caller that we're done
}
} };
synchronized(notificationLock) {
SunToolkit.postEvent(this,
new InvocationEvent(Toolkit.getDefaultToolkit(), runnable));
try {
notificationLock.wait(DISPOSAL_TIMEOUT);
} catch (InterruptedException e) { }
}
// Next, we interrupt all Threads in the ThreadGroup
this.threadGroup.interrupt();
// Note, the EventDispatchThread we've interrupted may dump an
// InterruptedException to the console here. This needs to be
// fixed in the EventDispatchThread, not here.
// Next, we sleep 10ms at a time, waiting for all of the active
// Threads in the ThreadGroup to exit.
long startTime = System.currentTimeMillis();
long endTime = startTime + (long)THREAD_INTERRUPT_TIMEOUT;
while ((this.threadGroup.activeCount() > 0) &&
(System.currentTimeMillis() < endTime)) {
try {
Thread.sleep(10);
} catch (InterruptedException e) { }
}
// Then, we stop any remaining Threads
this.threadGroup.stop();
// Next, we sleep 10ms at a time, waiting for all of the active
// Threads in the ThreadGroup to die.
startTime = System.currentTimeMillis();
endTime = startTime + (long)THREAD_INTERRUPT_TIMEOUT;
while ((this.threadGroup.activeCount() > 0) &&
(System.currentTimeMillis() < endTime)) {
try {
Thread.sleep(10);
} catch (InterruptedException e) { }
}
// Next, we remove this and all subThreadGroups from threadGroup2appContext
int numSubGroups = this.threadGroup.activeGroupCount();
if (numSubGroups > 0) {
ThreadGroup [] subGroups = new ThreadGroup[numSubGroups];
numSubGroups = this.threadGroup.enumerate(subGroups);
for (int subGroup = 0; subGroup < numSubGroups; subGroup++) {
threadGroup2appContext.remove(subGroups[subGroup]);
}
}
threadGroup2appContext.remove(this.threadGroup);
MostRecentThreadAppContext recent = mostRecentThreadAppContext;
if ((recent != null) && (recent.appContext == this))
mostRecentThreadAppContext = null;
// If the "most recent" points to this, clear it for GC
// Finally, we destroy the ThreadGroup entirely.
try {
this.threadGroup.destroy();
} catch (IllegalThreadStateException e) {
// Fired if not all the Threads died, ignore it and proceed
}
this.table.clear(); // Clear out the Hashtable to ease garbage collection
numAppContexts--;
}
private MostRecentKeyValue mostRecentKeyValue = null;
/**
* Returns the value to which the specified key is mapped in this context.
*
* @paramkeya key in the AppContext.
* @return the value to which the key is mapped in this AppContext;
* <code>null</code> if the key is not mapped to any value.
* @see#put(Object, Object)
* @sinceJDK1.2
*/
public Object get(Object key) {
// Note: this most recent key/value caching is thread-hot.
// A simple test using SwingSet found that 72% of lookups
// were matched using the most recent key/value. By instantiating
// a simple MostRecentKeyValue object on cache misses, the
// cache hits can be processed without synchronization.
MostRecentKeyValue recent = mostRecentKeyValue;
if ((recent != null) && (recent.key == key))
return recent.value;
Object value = table.get(key);
mostRecentKeyValue = new MostRecentKeyValue(key, value);
return value;
}
/**
* Maps the specified <code>key</code> to the specified
* <code>value</code> in this AppContext. Neither the key nor the
* value can be <code>null</code>.
* <p>
* The value can be retrieved by calling the <code>get</code> method
* with a key that is equal to the original key.
*
* @paramkeythe AppContext key.
* @paramvaluethe value.
* @returnthe previous value of the specified key in this
* AppContext, or <code>null</code> if it did not have one.
* @exception NullPointerException if the key or value is
*<code>null</code>.
* @see#get(Object)
* @sinceJDK1.2
*/
public Object put(Object key, Object value) {
MostRecentKeyValue recent = mostRecentKeyValue;
if ((recent != null) && (recent.key == key))
recent.value = value;
return table.put(key, value);
}
/**
* Removes the key (and its corresponding value) from this
* AppContext. This method does nothing if the key is not in the
* AppContext.
*
* @paramkeythe key that needs to be removed.
* @return the value to which the key had been mapped in this AppContext,
* or <code>null</code> if the key did not have a mapping.
* @sinceJDK1.2
*/
public Object remove(Object key) {
MostRecentKeyValue recent = mostRecentKeyValue;
if ((recent != null) && (recent.key == key))
recent.value = null;
return table.remove(key);
}
/**
* Returns the root ThreadGroup for all Threads contained within
* this AppContext.
* @sinceJDK1.2
*/
public ThreadGroup getThreadGroup() {
return threadGroup;
}
/**
* Returns a string representation of this AppContext.
* @sinceJDK1.2
*/
public String toString() {
return getClass().getName() + "[threadGroup=" + threadGroup.getName() + "]";
}
private void releaseClipboard() {
Clipboard clip = Toolkit.getDefaultToolkit().getSystemClipboard();
if ( clip instanceof SunClipboard ) {
((SunClipboard)clip).clearAppContext();
}
}
}
final class MostRecentThreadAppContext {
final Thread thread;
final AppContext appContext;
MostRecentThreadAppContext(Thread key, AppContext value) {
thread = key;
appContext = value;
}
}
final class MostRecentKeyValue {
final Object key;
Object value;
MostRecentKeyValue(Object k, Object v) {
key = k;
value = v;
}
}
walpj at 2007-6-29 10:46:48 >
