first commit

This commit is contained in:
Anton Budylin
2026-04-14 10:12:51 +03:00
commit ea171ed95a
247 changed files with 42642 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "co.tinode.tinodesdk"
compileSdk = 35
defaultConfig {
minSdk = 26
buildConfigField("String", "VERSION_NAME", "\"0.16.5\"")
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("com.google.code.gson:gson:2.11.0")
implementation("com.google.firebase:firebase-messaging-ktx:24.1.0")
// Jackson (JSON serialization)
implementation("com.fasterxml.jackson.core:jackson-core:2.17.2")
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.17.2")
// Java-WebSocket (WebSocket client)
implementation("org.java-websocket:Java-WebSocket:1.5.7")
// ICU4J (BreakIterator for grapheme cluster handling)
implementation("com.ibm.icu:icu4j:75.1")
}

View File

@@ -0,0 +1,3 @@
# Keep Tinode SDK classes
-keep class co.tinode.tinodesdk.** { *; }
-keep class co.tinode.tinodesdk.model.** { *; }

View File

@@ -0,0 +1,2 @@
# tinodesdk proguard rules
-keep class co.tinode.tinodesdk.** { *; }

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@@ -0,0 +1,7 @@
package co.tinode.tinodesdk;
/**
* Thrown when the user is already subscribed to topic.
*/
public class AlreadySubscribedException extends IllegalStateException {
}

View File

@@ -0,0 +1,7 @@
package co.tinode.tinodesdk;
/**
* Thrown when the action requires authentication.
*/
public class AuthenticationRequiredException extends IllegalStateException {
}

View File

@@ -0,0 +1,277 @@
package co.tinode.tinodesdk;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.ToIntFunction;
import java.util.stream.Collectors;
import co.tinode.tinodesdk.model.Description;
import co.tinode.tinodesdk.model.Drafty;
import co.tinode.tinodesdk.model.MetaSetDesc;
import co.tinode.tinodesdk.model.MsgServerData;
import co.tinode.tinodesdk.model.MsgServerMeta;
import co.tinode.tinodesdk.model.MsgSetMeta;
import co.tinode.tinodesdk.model.PrivateType;
import co.tinode.tinodesdk.model.ServerMessage;
import co.tinode.tinodesdk.model.Subscription;
import co.tinode.tinodesdk.model.TheCard;
/**
* Communication topic: a Slf, P2P or Group.
*/
public class ComTopic<DP extends TheCard> extends Topic<DP,PrivateType,DP,PrivateType> {
public ComTopic(Tinode tinode, Subscription<DP,PrivateType> sub) {
super(tinode, sub);
}
public ComTopic(Tinode tinode, String name, Description<DP,PrivateType> desc) {
super(tinode, name, desc);
}
public ComTopic(Tinode tinode, String name, Listener<DP,PrivateType,DP,PrivateType> l) {
super(tinode, name, l);
}
public ComTopic(Tinode tinode, Listener l, boolean isChannel) {
//noinspection unchecked
super(tinode, l, isChannel);
}
/**
* Subscribe to topic.
*/
public PromisedReply<ServerMessage> subscribe() {
if (isNew()) {
MetaSetDesc<DP, PrivateType> desc = new MetaSetDesc<>(mDesc.pub, mDesc.priv);
if (mDesc.pub != null) {
desc.attachments = mDesc.pub.getPhotoRefs();
}
return subscribe(new MsgSetMeta.Builder<DP, PrivateType>().with(desc).with(mTags).build(), null);
}
return super.subscribe();
}
public void setComment(String comment) {
PrivateType p = super.getPriv();
if (p == null) {
p = new PrivateType();
}
p.setComment(comment);
super.setPriv(p);
}
/**
* Read comment from the Private field.
* @return comment or null if comment or Private is not set.
*/
public String getComment() {
PrivateType p = super.getPriv();
return p != null ? p.getComment() : null;
}
/**
* Set message as pinned or unpinned by adding it to aux.pins array.
*
* @param seq - seq ID of the message to pin or un-pin.
* @param pin - true to pin the message, false to un-pin.
*
* @return Promise to be resolved/rejected when the server responds to request.
*/
public PromisedReply<ServerMessage> pinMessage(int seq, boolean pin) {
Object val = getAux("pins");
List<Integer> pinned;
if (val instanceof List) {
// Creating a copy, otherwise changes here will affect values saved in topic.
pinned = ((List<?>) val).stream()
.map(obj -> obj instanceof Number ? ((Number) obj).intValue() : null)
.filter(Objects::nonNull)
.collect(Collectors.toList());
} else {
pinned = new ArrayList<>();
}
boolean changed = false;
if (pin) {
if (!pinned.contains(seq)) {
changed = true;
if (pinned.size() == Tinode.MAX_PINNED_COUNT) {
pinned.remove(0);
}
pinned.add(seq);
}
} else {
changed = pinned.removeIf(pseq -> pseq == seq);
}
if (changed) {
Map<String, Object> aux = new HashMap<>();
aux.put("pins", !pinned.isEmpty() ? pinned : Tinode.NULL_VALUE);
return setMeta(new MsgSetMeta.Builder<DP, PrivateType>().with(aux).build());
}
return new PromisedReply<>((ServerMessage) null);
}
/**
* Check if the message with a given seqID is pinned.
* @param seq seqID of the message to check.
* @return true if the message is pinned, false otherwise.
*/
public boolean isPinned(int seq) {
Object val = getAux("pins");
return val instanceof List && ((List) val).contains(seq);
}
/**
* Get list of pinned seqIDs.
* @return array of pinned seqIDs or null if there are no pinned messages.
*/
public int[] getPinned() {
Object val = getAux("pins");
if (val instanceof List) {
int[] pinned = ((List<?>) val).stream()
.mapToInt((ToIntFunction<Object>) value ->
value instanceof Number ? ((Number) value).intValue() : 0)
.filter(value -> value > 0)
.toArray();
return pinned.length > 0 ? pinned : null;
}
return null;
}
/**
* Get hash code of the pinned seqIDs.
* Changes every time the pinned seqIDs change.
* @return hash code of the pinned seqIDs.
*/
public int getPinnedHash() {
Object val = getAux("pins");
if (val instanceof List) {
return val.hashCode();
}
return 0;
}
/**
* Get count of pinned messages. The count could be wrong of the list of
* pinned messages contains elements other than Number.
* @return count of pinned messages.
*/
public int pinnedCount() {
Object val = getAux("pins");
if (val instanceof List) {
return ((List) val).size();
}
return 0;
}
/**
* Checks if the topic is archived. Not all topics support archiving.
* @return true if the topic is archived, false otherwise.
*/
@Override
public boolean isArchived() {
PrivateType p = super.getPriv();
Boolean arch = (p != null ? p.isArchived() : Boolean.FALSE);
return arch != null ? arch : false;
}
/**
* Checks if the topic is a channel.
* @return true if the topic is a channel, false otherwise.
*/
public boolean isChannel() {
return mName != null && isChannel(mName);
}
/**
* Checks if the topic can be accessed as channel.
* @return true if the topic is accessible as a channel.
*/
public boolean hasChannelAccess() {
return mDesc.chan;
}
/**
* Sets flag that the topic is accessible as a channel.
* @param access <code>true</code> to indicate that the topic is accessible as a channel.
*/
public void setHasChannelAccess(boolean access) {
mDesc.chan = access;
}
/**
* In P2P topics get peer's subscription.
*
* @return peer's subscription.
*/
public Subscription<DP, PrivateType> getPeer() {
if (isP2PType()) {
return super.getSubscription(getName());
}
return null;
}
// Handle special cases.
@SuppressWarnings("unchecked")
public Subscription<DP, PrivateType> getSubscription(String key) {
if (isSlfType()) {
Subscription<DP, PrivateType> sub = new Subscription<>();
sub.pub = getPub();
return sub;
}
Subscription<DP, PrivateType> sub = super.getSubscription(key);
if (sub == null) {
return null;
}
if (isP2PType() && sub.pub == null) {
sub.pub = getName().equals(key) ?
getPub() : (DP) mTinode.getMeTopic().getPub();
}
return sub;
}
/**
* Archive topic by issuing {@link Topic#setMeta} with priv set to {arch: true/false}.
*
* @throws NotSubscribedException if the client is not subscribed to the topic
* @throws NotConnectedException if there is no connection to the server
*/
public PromisedReply<ServerMessage> updateArchived(final boolean arch) {
PrivateType priv = new PrivateType();
priv.setArchived(arch);
return setMeta(new MsgSetMeta.Builder<DP, PrivateType>().with(new MetaSetDesc<>(null, priv)).build());
}
public static class ComListener<DP> implements Listener<DP,PrivateType,DP,PrivateType> {
/** {meta} message received */
public void onMeta(MsgServerMeta<DP,PrivateType,DP,PrivateType> meta) {}
/** Called by MeTopic when topic descriptor as contact is updated */
public void onContUpdate(Subscription<DP,PrivateType> sub) {}
}
@Override
protected void routeData(MsgServerData data) {
if (data.head != null && data.content != null) {
// Rewrite VC body with info from the headers.
try {
String state = (String) data.head.get("webrtc");
String mime = (String) data.head.get("mime");
if (state != null && Drafty.MIME_TYPE.equals(mime)) {
boolean outgoing = ((!isChannel() && data.from == null) || mTinode.isMe(data.from));
Drafty.updateVideoEnt(data.content, data.head, !outgoing);
}
} catch (ClassCastException ignored) {}
}
super.routeData(data);
}
// Just for convenience.
public static class CTListener<DP extends TheCard> implements Listener<DP,PrivateType,DP,PrivateType> {
}
}

View File

@@ -0,0 +1,311 @@
package co.tinode.tinodesdk;
import android.util.Log;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.drafts.Draft_6455;
import org.java_websocket.extensions.permessage_deflate.PerMessageDeflateExtension;
import org.java_websocket.handshake.ServerHandshake;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
/**
* A thinly wrapped websocket connection.
*/
public class Connection extends WebSocketClient {
private static final String TAG = "Connection";
private static final int CONNECTION_TIMEOUT = 3000; // in milliseconds
// Connection states
// TODO: consider extending ReadyState
private enum State {
// Created. No attempts were made to reconnect.
NEW,
// Created, in process of creating or restoring connection.
CONNECTING,
// Connected.
CONNECTED,
// Disconnected. A thread is waiting to reconnect again.
WAITING_TO_RECONNECT,
// Disconnected. Not waiting to reconnect.
CLOSED
}
private final WsListener mListener;
// Connection status
private State mStatus;
// If connection should try to reconnect automatically.
private boolean mAutoreconnect;
// This connection is a background connection.
// The value is reset when the connection is successful.
private boolean mBackground;
// Exponential backoff/reconnecting
final private ExpBackoff backoff = new ExpBackoff();
@SuppressWarnings("WeakerAccess")
protected Connection(URI endpoint, String apikey, WsListener listener) {
super(normalizeEndpoint(endpoint), new Draft_6455(new PerMessageDeflateExtension()),
wrapApiKey(apikey), CONNECTION_TIMEOUT);
setReuseAddr(true);
mListener = listener;
mStatus = State.NEW;
mAutoreconnect = false;
mBackground = false;
}
private static Map<String,String> wrapApiKey(String apikey) {
Map<String, String> headers = new HashMap<>();
headers.put("X-Tinode-APIKey",apikey);
return headers;
}
private static URI normalizeEndpoint(URI endpoint) {
String path = endpoint.getPath();
if (path.isEmpty()) {
path = "/";
} else if (path.lastIndexOf("/") != path.length() - 1) {
path += "/";
}
path += "channels"; // ws://www.example.com:12345/v0/channels
String scheme = endpoint.getScheme();
// Normalize scheme to ws or wss.
scheme = ("wss".equals(scheme) || "https".equals(scheme)) ? "wss" : "ws";
int port = endpoint.getPort();
if (port < 0) {
port = "wss".equals(scheme) ? 443 : 80;
}
try {
endpoint = new URI(scheme,
endpoint.getUserInfo(),
endpoint.getHost(),
port,
path,
endpoint.getQuery(),
endpoint.getFragment());
} catch (URISyntaxException e) {
Log.w(TAG, "Invalid endpoint URI", e);
}
return endpoint;
}
private void connectSocket(final boolean reconnect) {
new Thread(() -> {
try {
if (reconnect) {
reconnectBlocking();
} else {
connectBlocking(CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS);
}
if ("wss".equals(uri.getScheme())) {
// SNI: Verify server host name.
SSLSession sess = ((SSLSocket) getSocket()).getSession();
String hostName = uri.getHost();
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(hostName, sess)) {
close();
throw new SSLHandshakeException("SNI verification failed. Expected: '" + uri.getHost() +
"', actual: '" + sess.getPeerPrincipal() + "'");
}
}
} catch (Exception ex) {
Log.w(TAG, "WS connection failed", ex);
if (mListener != null) {
mListener.onError(Connection.this, ex);
}
}
}).start();
}
/**
* Establish a connection with the server. It opens or reopens a websocket in a separate
* thread.
* <p>
* This is a non-blocking call.
*
* @param autoReconnect if connection is dropped, reconnect automatically
*/
@SuppressWarnings("WeakerAccess")
synchronized public void connect(boolean autoReconnect, boolean background) {
mAutoreconnect = autoReconnect;
mBackground = background;
switch (mStatus) {
case CONNECTED:
case CONNECTING:
// Already connected or in process of connecting: do nothing.
break;
case WAITING_TO_RECONNECT:
backoff.wakeUp();
break;
case NEW:
mStatus = State.CONNECTING;
connectSocket(false);
break;
case CLOSED:
mStatus = State.CONNECTING;
connectSocket(true);
break;
// exhaustive, no default:
}
}
/**
* Gracefully close websocket connection. The socket will attempt
* to send a frame to the server.
* <p>
* The call is idempotent: if connection is already closed it does nothing.
* This method is non-blocking.
*/
@SuppressWarnings("WeakerAccess")
public void disconnect() {
boolean wakeUp;
synchronized (this) {
wakeUp = mAutoreconnect;
mAutoreconnect = false;
}
// Close the socket on a background thread to avoid blocking the main thread.
// WebSocketClient.close() may block waiting to acquire a lock
// (even though it's intended to be non-blocking).
new Thread(this::close).start();
if (wakeUp) {
// Make sure we are not waiting to reconnect
backoff.wakeUp();
}
}
/**
* Check if the socket is OPEN.
*
* @return true if the socket is OPEN, false otherwise;
*/
@SuppressWarnings("WeakerAccess")
public boolean isConnected() {
return isOpen();
}
/**
* Check if the socket is waiting to reconnect.
*
* @return true if the socket is OPEN, false otherwise;
*/
@SuppressWarnings("WeakerAccess")
public boolean isWaitingToReconnect() {
return mStatus == State.WAITING_TO_RECONNECT;
}
/**
* Reset exponential backoff counter to zero.
* If autoreconnect is true and WsListener is provided, then WsListener.onConnect must call
* this method.
*/
@SuppressWarnings("WeakerAccess")
public void backoffReset() {
backoff.reset();
}
@Override
public void onOpen(ServerHandshake handshakeData) {
synchronized (this) {
mStatus = State.CONNECTED;
}
if (mListener != null) {
boolean bkg = mBackground;
mBackground = false;
mListener.onConnect(this, bkg);
} else {
backoff.reset();
}
}
@Override
public void onMessage(String message) {
if (mListener != null) {
mListener.onMessage(this, message);
}
}
@Override
public void onMessage(ByteBuffer blob) {
// do nothing, server does not send binary frames
Log.w(TAG, "binary message received (should not happen)");
}
@Override
public void onClose(int code, String reason, boolean remote) {
// Avoid infinite recursion
synchronized (this) {
if (mStatus == State.WAITING_TO_RECONNECT) {
return;
} else if (mAutoreconnect) {
mStatus = State.WAITING_TO_RECONNECT;
} else {
mStatus = State.CLOSED;
}
}
if (mListener != null) {
mListener.onDisconnect(this, remote, code, reason);
}
if (mAutoreconnect) {
new Thread(() -> {
while (mStatus == State.WAITING_TO_RECONNECT) {
backoff.doSleep();
synchronized (Connection.this) {
// Check if we no longer need to connect.
if (mStatus != State.WAITING_TO_RECONNECT) {
break;
}
mStatus = State.CONNECTING;
}
connectSocket(true);
}
}).start();
}
}
@Override
public void onError(Exception ex) {
Log.w(TAG, "Websocket error", ex);
if (mListener != null) {
mListener.onError(this, ex);
}
}
interface WsListener {
default void onConnect(Connection conn, boolean background) {
}
default void onMessage(Connection conn, String message) {
}
default void onDisconnect(Connection conn, boolean byServer, int code, String reason) {
}
default void onError(Connection conn, Exception err) {
}
}
}

View File

@@ -0,0 +1,78 @@
package co.tinode.tinodesdk;
import java.util.Random;
/**
* Exponential backoff for reconnects.
*/
public class ExpBackoff {
// Minimum delay = 1000ms, expected ~1500ms;
private static final int BASE_SLEEP_MS = 1000;
// Maximum delay 2^10 ~ 2000 seconds ~ 34 min.
private static final int MAX_SHIFT = 10;
private final Random random = new Random();
private int attempt;
@SuppressWarnings("WeakerAccess")
public ExpBackoff() {
this.attempt = 0;
}
private Thread currentThread = null;
/**
* Increment attempt counter and return time to sleep in milliseconds
* @return time to sleep in milliseconds
*/
@SuppressWarnings("WeakerAccess")
public long getNextDelay() {
if (attempt > MAX_SHIFT) {
attempt = MAX_SHIFT;
}
long delay = (long) BASE_SLEEP_MS * (1L << attempt) + random.nextInt(BASE_SLEEP_MS * (1 << attempt));
attempt++;
return delay;
}
/**
* Pause the current thread for the appropriate number of milliseconds.
* This method cannot be synchronized!
*
* @return false if the sleep was interrupted, true otherwise
*/
@SuppressWarnings({"WeakerAccess", "UnusedReturnValue"})
public boolean doSleep() {
boolean result;
try {
currentThread = Thread.currentThread();
Thread.sleep(getNextDelay());
result = true;
} catch (InterruptedException e) {
result = false;
} finally {
currentThread = null;
}
return result;
}
public void reset() {
this.attempt = 0;
}
public int getAttemptCount() {
return attempt;
}
@SuppressWarnings({"WeakerAccess", "UnusedReturnValue"})
synchronized public boolean wakeUp() {
reset();
if (currentThread != null) {
currentThread.interrupt();
return true;
}
return false;
}
}

View File

@@ -0,0 +1,143 @@
package co.tinode.tinodesdk;
import com.fasterxml.jackson.databind.JavaType;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import co.tinode.tinodesdk.model.Drafty;
import co.tinode.tinodesdk.model.MsgServerMeta;
import co.tinode.tinodesdk.model.MsgSetMeta;
import co.tinode.tinodesdk.model.ServerMessage;
import co.tinode.tinodesdk.model.Subscription;
// Topic's Public and Private are String. Subscription Public is VCard, Private is String[].
public class FndTopic<SP> extends Topic<String, String, SP, String[]> {
@SuppressWarnings("unused")
private static final String TAG = "FndTopic";
@SuppressWarnings("WeakerAccess")
public FndTopic(Tinode tinode, Listener<String,String,SP,String[]> l) {
super(tinode, Tinode.TOPIC_FND, l);
}
@SuppressWarnings("unused")
public void setTypes(JavaType typeOfSubPu) {
mTinode.setFndTypeOfMetaPacket(typeOfSubPu);
}
@Override
public PromisedReply<ServerMessage> setMeta(final MsgSetMeta<String, String> meta) {
if (mSubs != null) {
mSubs = null;
mSubsUpdated = null;
mNotifier.notifySubsUpdated();
}
return super.setMeta(meta);
}
@Override
protected PromisedReply<ServerMessage> publish(Drafty content, Map<String, Object> head, long id) {
throw new UnsupportedOperationException();
}
/**
* Add subscription to cache. Needs to be overridden in FndTopic because it keeps subs indexed
* by either user or topic value.
*
* @param sub subscription to add to cache
*/
@Override
protected void addSubToCache(Subscription<SP,String[]> sub) {
if (mSubs == null) {
mSubs = new HashMap<>();
}
mSubs.put(sub.getUnique(), sub);
}
@Override
protected void routeMetaSub(MsgServerMeta<String, String,SP,String[]> meta) {
for (Subscription<SP,String[]> upd : meta.sub) {
Subscription<SP,String[]> sub = getSubscription(upd.getUnique());
if (sub != null) {
sub.merge(upd);
} else {
sub = upd;
addSubToCache(sub);
}
mNotifier.notifyMetaSub(sub);
}
mNotifier.notifySubsUpdated();
}
@Override
public Subscription<SP,String[]> getSubscription(String key) {
return mSubs != null ? mSubs.get(key) : null;
}
@Override
public Collection<Subscription<SP,String[]>> getSubscriptions() {
return mSubs != null ? mSubs.values() : null;
}
@Override
protected void setStorage(Storage store) {
/* Do nothing: all fnd data is transient. */
}
/**
* Check if the given tag is unique by asking the server.
* @param tag tag to check.
* @return promise to be resolved with true if the tag is unique, false otherwise.
*/
public PromisedReply<Boolean> checkTagUniqueness(final String tag, final String caller) {
PromisedReply<Boolean> result = new PromisedReply<>();
subscribe(null, null)
.thenApply(new PromisedReply.SuccessListener<>() {
@Override
public PromisedReply<ServerMessage> onSuccess(ServerMessage unused) {
return setDescription(tag, null, null);
}
})
.thenApply(new PromisedReply.SuccessListener<>() {
@Override
public PromisedReply<ServerMessage> onSuccess(ServerMessage unused) {
return getMeta(getMetaGetBuilder().withTags().build());
}
})
.thenApply(new PromisedReply.SuccessListener<>() {
@Override
public PromisedReply<ServerMessage> onSuccess(ServerMessage response) throws Exception {
if (response.meta == null || response.meta.tags == null) {
result.resolve(true);
return null;
}
String[] tags = response.meta.tags;
for (String t : tags) {
if (t != null && !t.equals(caller)) {
result.resolve(false);
return null;
}
}
result.resolve(true);
return null;
}
})
.thenCatch(new PromisedReply.FailureListener<>() {
@Override
public <E extends Exception> PromisedReply<ServerMessage> onFailure(E err) throws Exception {
result.reject(err);
return null;
}
});
return result;
}
// Just for convenience.
public static class FndListener<SP> implements Listener<String, String, SP, String[]> {
}
}

View File

@@ -0,0 +1,7 @@
package co.tinode.tinodesdk;
/**
* Exception thrown when certain non-idempotent operations are already in progress, such as login.
*/
public class InProgressException extends IllegalStateException {
}

View File

@@ -0,0 +1,266 @@
package co.tinode.tinodesdk;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CancellationException;
import co.tinode.tinodesdk.model.MsgServerCtrl;
import co.tinode.tinodesdk.model.ServerMessage;
public class LargeFileHelper {
private static final int BUFFER_SIZE = 65536;
private static final String TWO_HYPHENS = "--";
private static final String BOUNDARY = "*****" + System.currentTimeMillis() + "*****";
private static final String LINE_END = "\r\n";
private final URL mUrlUpload;
private final String mHost;
private final String mApiKey;
private final String mAuthToken;
private final String mUserAgent;
private boolean mCanceled = false;
private int mReqId = 1;
public LargeFileHelper(URL urlUpload, String apikey, String authToken, String userAgent) {
mUrlUpload = urlUpload;
mHost = mUrlUpload.getHost();
mApiKey = apikey;
mAuthToken = authToken;
mUserAgent = userAgent;
}
// Upload file out of band. Blocking operation: it should not be called on the UI thread.
public ServerMessage upload(@NotNull InputStream in, @NotNull String filename, @NotNull String mimetype, long size,
@Nullable String topic, @Nullable FileHelperProgress progress)
throws IOException, CancellationException {
mCanceled = false;
HttpURLConnection conn = null;
ServerMessage msg;
try {
conn = (HttpURLConnection) mUrlUpload.openConnection();
conn.setDoOutput(true);
conn.setUseCaches(false);
conn.setRequestProperty("Connection", "Keep-Alive");
conn.setRequestProperty("User-Agent", mUserAgent);
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDARY);
conn.setRequestProperty("X-Tinode-APIKey", mApiKey);
if (mAuthToken != null) {
// mAuthToken could be null when uploading avatar on sign up.
conn.setRequestProperty("X-Tinode-Auth", "Token " + mAuthToken);
}
conn.setChunkedStreamingMode(0);
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(conn.getOutputStream()));
// Write req ID.
out.writeBytes(TWO_HYPHENS + BOUNDARY + LINE_END);
out.writeBytes("Content-Disposition: form-data; name=\"id\"" + LINE_END);
out.writeBytes(LINE_END);
out.writeBytes(++mReqId + LINE_END);
// Write topic.
if (topic != null) {
out.writeBytes(TWO_HYPHENS + BOUNDARY + LINE_END);
out.writeBytes("Content-Disposition: form-data; name=\"topic\"" + LINE_END);
out.writeBytes(LINE_END);
out.writeBytes(topic + LINE_END);
}
// File section.
out.writeBytes(TWO_HYPHENS + BOUNDARY + LINE_END);
// Content-Disposition: form-data; name="file"; filename="1519014549699.pdf"
out.writeBytes("Content-Disposition: form-data; name=\"file\"; ");
String encFileName = URLEncoder.encode(filename, "UTF-8");
if (filename.equals(encFileName)) {
// Plain ASCII file name.
out.writeBytes("filename=\"" + filename + "\"");
} else {
// URL-encoded file name.
out.writeBytes("filename*=UTF-8''" + encFileName);
}
out.writeBytes(LINE_END);
// Content-Type: application/pdf
out.writeBytes("Content-Type: " + mimetype + LINE_END);
out.writeBytes("Content-Transfer-Encoding: binary" + LINE_END);
out.writeBytes(LINE_END);
// File bytes.
copyStream(in, out, size, progress);
out.writeBytes(LINE_END);
// End of form boundary.
out.writeBytes(TWO_HYPHENS + BOUNDARY + TWO_HYPHENS + LINE_END);
out.flush();
out.close();
if (conn.getResponseCode() != 200) {
throw new IOException("Failed to upload: " + conn.getResponseMessage() +
" (" + conn.getResponseCode() + ")");
}
InputStream resp = new BufferedInputStream(conn.getInputStream());
msg = readServerResponse(resp);
resp.close();
} finally {
if (conn != null) {
conn.disconnect();
}
}
return msg;
}
// Uploads the file using Runnable, returns PromisedReply. Safe to call on UI thread.
public PromisedReply<ServerMessage> uploadAsync(@NotNull InputStream in, @NotNull String filename,
@NotNull String mimetype, long size,
@Nullable String topic, @Nullable FileHelperProgress progress) {
final PromisedReply<ServerMessage> result = new PromisedReply<>();
new Thread(() -> {
try {
ServerMessage msg = upload(in, filename, mimetype, size, topic, progress);
if (mCanceled) {
throw new CancellationException("Cancelled");
}
result.resolve(msg);
} catch (Exception ex) {
try {
result.reject(ex);
} catch (Exception ignored) {
}
}
}).start();
return result;
}
// Download file from the given URL if the URL's host is the default host. Should not be called on the UI thread.
public long download(String downloadFrom, OutputStream out, FileHelperProgress progress)
throws IOException, CancellationException {
URL url = new URL(downloadFrom);
long size = 0;
String scheme = url.getProtocol();
if (!scheme.equals("http") && !scheme.equals("https")) {
// As a security measure refuse to download using non-http(s) protocols.
return size;
}
HttpURLConnection urlConnection = null;
try {
urlConnection = (HttpURLConnection) url.openConnection();
if (url.getHost().equals(mHost)) {
// Send authentication only if the host is known.
urlConnection.setRequestProperty("X-Tinode-APIKey", mApiKey);
urlConnection.setRequestProperty("X-Tinode-Auth", "Token " + mAuthToken);
}
InputStream in = new BufferedInputStream(urlConnection.getInputStream());
return copyStream(in, out, urlConnection.getContentLength(), progress);
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
}
}
// Downloads the file using Runnable, returns PromisedReply. Safe to call on UI thread.
public PromisedReply<Long> downloadFuture(final String downloadFrom,
final OutputStream out,
final FileHelperProgress progress) {
final PromisedReply<Long> result = new PromisedReply<>();
new Thread(() -> {
try {
Long size = download(downloadFrom, out, progress);
if (mCanceled) {
throw new CancellationException("Cancelled");
}
result.resolve(size);
} catch (Exception ex) {
try {
result.reject(ex);
} catch (Exception ignored) {
}
}
}).start();
return result;
}
// Try to cancel an ongoing upload or download.
public void cancel() {
mCanceled = true;
}
public boolean isCanceled() {
return mCanceled;
}
private int copyStream(@NotNull InputStream in, @NotNull OutputStream out, long size, @Nullable FileHelperProgress p)
throws IOException, CancellationException {
byte[] buffer = new byte[BUFFER_SIZE];
int len, sent = 0;
while ((len = in.read(buffer)) != -1) {
if (mCanceled) {
throw new CancellationException("Cancelled");
}
sent += len;
out.write(buffer, 0, len);
if (mCanceled) {
throw new CancellationException("Cancelled");
}
if (p != null) {
p.onProgress(sent, size);
}
}
return sent;
}
private ServerMessage readServerResponse(InputStream in) throws IOException {
MsgServerCtrl ctrl = null;
ObjectMapper mapper = Tinode.getJsonMapper();
JsonParser parser = mapper.getFactory().createParser(in);
if (parser.nextToken() != JsonToken.START_OBJECT) {
throw new JsonParseException(parser, "Packet must start with an object",
parser.currentLocation());
}
if (parser.nextToken() != JsonToken.END_OBJECT) {
String name = parser.currentName();
parser.nextToken();
JsonNode node = mapper.readTree(parser);
if (name.equals("ctrl")) {
ctrl = mapper.readValue(node.traverse(), MsgServerCtrl.class);
} else {
throw new JsonParseException(parser, "Unexpected message '" + name + "'",
parser.currentLocation());
}
}
return new ServerMessage(ctrl);
}
public interface FileHelperProgress {
void onProgress(long sent, long size);
}
public Map<String,String> headers() {
Map<String,String> headers = new HashMap<>();
headers.put("X-Tinode-APIKey", mApiKey);
headers.put("X-Tinode-Auth", "Token " + mAuthToken);
return headers;
}
}

View File

@@ -0,0 +1,19 @@
package co.tinode.tinodesdk;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* Empty interface to indicate a set of value which are not synced with the server.
* Used for persistent, such as local DB ids.
*/
public interface LocalData {
interface Payload {
}
@JsonIgnore
void setLocal(Payload value);
@JsonIgnore
Payload getLocal();
}

View File

@@ -0,0 +1,739 @@
package co.tinode.tinodesdk;
import android.util.Log;
import com.fasterxml.jackson.databind.JavaType;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import co.tinode.tinodesdk.model.Acs;
import co.tinode.tinodesdk.model.AcsHelper;
import co.tinode.tinodesdk.model.Credential;
import co.tinode.tinodesdk.model.Description;
import co.tinode.tinodesdk.model.Drafty;
import co.tinode.tinodesdk.model.MetaSetSub;
import co.tinode.tinodesdk.model.MsgServerCtrl;
import co.tinode.tinodesdk.model.MsgServerInfo;
import co.tinode.tinodesdk.model.MsgServerMeta;
import co.tinode.tinodesdk.model.MsgServerPres;
import co.tinode.tinodesdk.model.MsgSetMeta;
import co.tinode.tinodesdk.model.PrivateType;
import co.tinode.tinodesdk.model.ServerMessage;
import co.tinode.tinodesdk.model.Subscription;
/**
* MeTopic manages contact list. MeTopic::Private is unused.
*/
public class MeTopic<DP> extends Topic<DP,PrivateType,DP,PrivateType> {
private static final String TAG = "MeTopic";
protected MeNotifier<DP> mMeNotifier = new MeNotifier<>(mListeners);
@SuppressWarnings("WeakerAccess")
protected ArrayList<Credential> mCreds;
public MeTopic(Tinode tinode, Listener<DP,PrivateType,DP,PrivateType> l) {
super(tinode, Tinode.TOPIC_ME, l);
}
protected MeTopic(Tinode tinode, Description<DP,PrivateType> desc) {
super(tinode, Tinode.TOPIC_ME, desc);
}
public void setTypes(JavaType typeOfPu) {
mTinode.setMeTypeOfMetaPacket(typeOfPu);
}
@Override
protected void addSubToCache(Subscription<DP,PrivateType> sub) {
throw new UnsupportedOperationException();
}
@Override
protected void removeSubFromCache(Subscription sub) {
throw new UnsupportedOperationException();
}
@Override
public PromisedReply<ServerMessage> publish(Drafty content) {
throw new UnsupportedOperationException();
}
@Override
public PromisedReply<ServerMessage> publish(String content) {
throw new UnsupportedOperationException();
}
@Override
@SuppressWarnings("unchecked")
public Subscription getSubscription(String key) {
throw new UnsupportedOperationException();
}
@Override
public Collection<Subscription<DP,PrivateType>> getSubscriptions() {
throw new UnsupportedOperationException();
}
@Override
public Date getSubsUpdated() {
return mTinode.getTopicsUpdated();
}
/**
* Get current user's credentials, such as emails and phone numbers.
*/
public Credential[] getCreds() {
return mCreds != null ? mCreds.toArray(new Credential[]{}) : null;
}
public void setCreds(Credential[] creds) {
if (creds == null) {
mCreds = null;
} else {
mCreds = new ArrayList<>();
for (Credential cred : creds) {
if (cred.meth != null && cred.val != null) {
mCreds.add(cred);
}
}
Collections.sort(mCreds);
}
}
/**
* Delete credential.
*
* @param meth credential method (i.e. "tel" or "email").
* @param val value of the credential being deleted, i.e. "alice@example.com".
*/
public PromisedReply<ServerMessage> delCredential(String meth, String val) {
if (mAttached <= 0) {
if (mTinode.isConnected()) {
return new PromisedReply<>(new NotSubscribedException());
}
return new PromisedReply<>(new NotConnectedException());
}
final Credential cred = new Credential(meth, val);
return mTinode.delCredential(cred).thenApply(new PromisedReply.SuccessListener<>() {
@Override
public PromisedReply<ServerMessage> onSuccess(ServerMessage result) {
if (mCreds == null) {
return null;
}
int idx = findCredIndex(cred, false);
if (idx >= 0) {
mCreds.remove(idx);
if (mStore != null) {
mStore.topicUpdate(MeTopic.this);
}
// Notify listeners
mMeNotifier.notifyCredUpdated(mCreds.toArray(new Credential[]{}));
}
return null;
}
});
}
public PromisedReply<ServerMessage> confirmCred(final String meth, final String resp) {
return setMeta(new MsgSetMeta.Builder<DP,PrivateType>()
.with(new Credential(meth, null, resp, null)).build());
}
@Override
public PromisedReply<ServerMessage> updateMode(final String update) {
if (mDesc.acs == null) {
mDesc.acs = new Acs();
}
final AcsHelper mode = mDesc.acs.getWantHelper();
if (mode.update(update)) {
return setSubscription(new MetaSetSub(null, mode.toString()));
}
// The state is unchanged, return resolved promise.
return new PromisedReply<>((ServerMessage) null);
}
/**
* Topic sent an update to subscription, got a confirmation.
*
* @param params {ctrl} parameters returned by the server (could be null).
* @param sSub updated topic parameters.
*/
@Override
protected void update(Map<String, Object> params, MetaSetSub sSub) {
//noinspection unchecked
Map<String, String> acsMap = params != null ? (Map<String, String>) params.get("acs") : null;
Acs acs;
if (acsMap != null) {
acs = new Acs(acsMap);
} else {
acs = new Acs();
acs.setWant(sSub.mode);
}
boolean changed;
if (mDesc.acs == null) {
mDesc.acs = acs;
changed = true;
} else {
changed = mDesc.acs.merge(acs);
}
if (changed && mStore != null) {
mStore.topicUpdate(this);
}
}
/**
* Topic sent an update to description or subscription, got a confirmation, now
* update local data with the new info.
*
* @param ctrl {ctrl} packet sent by the server
* @param meta original {meta} packet updated topic parameters
*/
@Override
protected void update(MsgServerCtrl ctrl, MsgSetMeta<DP,PrivateType> meta) {
if (meta.desc != null) {
updatePinnedTopics(meta.desc.priv);
}
super.update(ctrl, meta);
if (meta.cred != null) {
routeMetaCred(meta.cred);
}
}
@Override
protected void routeMeta(MsgServerMeta<DP,PrivateType,DP,PrivateType> meta) {
if (meta.cred != null) {
routeMetaCred(meta.cred);
}
if (meta.desc != null) {
// Create or update 'me' user in storage.
User userMe = mTinode.getUser(mTinode.getMyId());
boolean changed;
if (userMe == null) {
userMe = mTinode.addUser(mTinode.getMyId(), meta.desc);
changed = true;
} else {
//noinspection unchecked
changed = userMe.merge(meta.desc);
}
if (changed && mStore != null) {
mStore.userUpdate(userMe);
}
updatePinnedTopics(meta.desc.priv);
}
super.routeMeta(meta);
}
@Override
public int getPinnedRank() {
return 0;
}
@Override
public void setPinnedRank(int pinned) {
/* do nothing */
}
/**
* Pin topic to the top of the contact list.
*
* @param topicName - Name of the topic to pin.
* @param pin - If true, pin the topic, otherwise unpin.
*
* @return Promise to be resolved/rejected when the server responds to request.
*/
@Override
public PromisedReply<ServerMessage> pinTopic(final @NotNull String topicName, boolean pin) {
if (mAttached <= 0) {
if (mTinode.isConnected()) {
return new PromisedReply<>(new NotSubscribedException());
}
return new PromisedReply<>(new NotConnectedException());
}
if (!isUserType(topicName)) {
return new PromisedReply<>(new IllegalArgumentException("Invalid topic type to pin"));
}
List<String> pinned = getPriv() != null ? getPriv().getPinnedTopics() : null;
ArrayList<String> tpin = pinned != null ?
// Creating a copy to leave original list unchanged.
new ArrayList<>(pinned) :
// New empty list.
new ArrayList<>();
boolean found = tpin.contains(topicName);
if ((pin && found) || (!pin && !found)) {
// Nothing to do, return resolved promise.
return new PromisedReply<>(null);
}
if (pin) {
// Add topic to the top of the pinned list.
tpin.add(0, topicName);
} else {
// Remove topic from the pinned list.
tpin.remove(topicName);
}
final PrivateType priv = new PrivateType();
priv.setPinnedTopics(tpin);
return setDescription(null, priv, null);
}
/**
* Get the rank of the pinned topic.
* @param topicName - Name of the topic to check.
*
* @return numeric rank of the pinned topic in the range 1..N (N being the top,
* N - the number of pinned topics) or 0 if not pinned.
*/
@Override
public int pinnedTopicRank(final @NotNull String topicName) {
PrivateType priv = getPriv();
if (priv == null) {
return 0;
}
return priv.getPinnedRank(topicName);
}
/**
* Check of the given topic is pinned (pinnedRank > 0).
* @param topicName - Name of the topic to check.
*
* @return true if pinned, false otherwise.
*/
public boolean isPinned(final @NotNull String topicName) {
PrivateType priv = getPriv();
if (priv == null) {
return false;
}
return priv.getPinnedRank(topicName) > 0;
}
private void updatePinnedTopics(final PrivateType priv) {
List<String> newPins = priv != null ? priv.getPinnedTopics() : null;
if (newPins == null) {
return;
}
// Update pinned rank for all pinned topics.
int rank = newPins.size();
for (String topicName : newPins) {
Topic topic = mTinode.getTopic(topicName);
if (topic != null) {
topic.setPinnedRank(rank);
if (mStore != null) {
mStore.topicUpdate(topic);
}
}
rank --;
}
List<String> thesePins = getPriv() != null ? getPriv().getPinnedTopics() : null;
if (thesePins == null || thesePins.isEmpty()) {
return;
}
// Unpin topics that were removed from the pinned list.
for (String topicName : thesePins) {
if (!newPins.contains(topicName)) {
Topic topic = mTinode.getTopic(topicName);
if (topic != null) {
topic.setPinnedRank(0);
if (mStore != null) {
mStore.topicUpdate(topic);
}
}
}
}
}
@Override
protected void routeMetaSub(MsgServerMeta<DP,PrivateType,DP,PrivateType> meta) {
for (Subscription<DP,PrivateType> sub : meta.sub) {
processOneSub(sub);
}
mMeNotifier.notifySubsUpdated();
}
@SuppressWarnings("unchecked")
private void processOneSub(Subscription<DP,PrivateType> sub) {
// Handle topic.
Topic topic = mTinode.getTopic(sub.topic);
if (topic != null) {
// This is an existing topic.
if (sub.deleted != null) {
// Expunge deleted topic
if (topic.isDeleted()) {
mTinode.stopTrackingTopic(sub.topic);
topic.expunge(true);
} else {
topic.expunge(false);
}
topic = null;
} else {
// Update its record in memory and in the database.
if (topic.update(sub)) {
// Notify topic to update self.
topic.mNotifier.notifyMetaDesc(topic.mDesc);
}
}
} else if (sub.deleted == null) {
// This is a new topic. Register it and write to DB.
topic = mTinode.newTopic(sub);
topic.persist();
} else {
Log.w(TAG, "Request to delete an unknown topic: " + sub.topic);
}
if (topic != null) {
int pinnedRank = pinnedTopicRank(sub.topic);
if (topic.getPinnedRank() != pinnedRank) {
topic.setPinnedRank(pinnedRank);
if (mStore != null) {
mStore.topicUpdate(topic);
}
}
// Use p2p topic to update user's record.
if (topic.getTopicType() == TopicType.P2P) {
// Use P2P description to generate and update user
User user = mTinode.getUser(topic.getName());
boolean changed;
if (user == null) {
user = mTinode.addUser(topic.getName(), topic.mDesc);
changed = true;
} else {
changed = user.merge(topic.mDesc);
}
if (changed && mStore != null) {
mStore.userUpdate(user);
}
}
}
mMeNotifier.notifyMetaSub(sub);
}
private int findCredIndex(Credential other, boolean anyUnconfirmed) {
int i = 0;
for (Credential cred : mCreds) {
if (cred.meth.equals(other.meth) && ((anyUnconfirmed && !cred.isDone()) || cred.val.equals(other.val))) {
return i;
}
i++;
}
return -1;
}
private void processOneCred(Credential cred) {
if (cred.meth == null) {
// Skip invalid method;
return;
}
boolean changed = false;
if (cred.val != null) {
if (mCreds == null) {
// Empty list. Create and add.
mCreds = new ArrayList<>();
mCreds.add(cred);
} else {
// Try finding this credential among confirmed or not.
int idx = findCredIndex(cred, false);
if (idx < 0) {
// Not found.
if (!cred.isDone()) {
// Unconfirmed credential replaces previous unconfirmed credential of the same method.
idx = findCredIndex(cred, true);
if (idx >= 0) {
// Remove previous unconfirmed credential.
mCreds.remove(idx);
}
}
mCreds.add(cred);
} else {
// Found. Maybe change 'done' status.
Credential el = mCreds.get(idx);
el.done = cred.isDone();
}
}
changed = true;
} else if (cred.resp != null && mCreds != null) {
// Handle credential confirmation.
int idx = findCredIndex(cred, true);
if (idx >= 0) {
Credential el = mCreds.get(idx);
el.done = true;
changed = true;
}
}
if (changed) {
if (mCreds != null) {
Collections.sort(mCreds);
}
if (mStore != null) {
mStore.topicUpdate(this);
}
}
}
@SuppressWarnings("WeakerAccess")
protected void routeMetaCred(Credential cred) {
processOneCred(cred);
mMeNotifier.notifyCredUpdated(mCreds.toArray(new Credential[]{}));
}
@SuppressWarnings("WeakerAccess")
protected void routeMetaCred(Credential[] creds) {
mCreds = new ArrayList<>();
for (Credential cred : creds) {
if (cred.meth != null && cred.val != null) {
mCreds.add(cred);
}
}
Collections.sort(mCreds);
if (mStore != null) {
mStore.topicUpdate(this);
}
mMeNotifier.notifyCredUpdated(creds);
}
@Override
protected void routePres(MsgServerPres pres) {
MsgServerPres.What what = MsgServerPres.parseWhat(pres.what);
if (what == MsgServerPres.What.TERM) {
super.routePres(pres);
return;
}
if (what == MsgServerPres.What.UPD) {
if (Tinode.TOPIC_ME.equals(pres.src)) {
// Update to me topic itself.
getMeta(getMetaGetBuilder().withDesc().build());
} else {
// pub/priv updated: fetch subscription update.
getMeta(getMetaGetBuilder().withSub(pres.src).build());
}
} else {
Topic topic = mTinode.getTopic(pres.src);
if (topic != null) {
switch (what) {
case ON: // topic came online
topic.setOnline(true);
break;
case OFF: // topic went offline
topic.setOnline(false);
topic.setLastSeen(new Date());
break;
case MSG: // new message received
topic.setSeqAndFetch(pres.seq);
if (pres.act == null || mTinode.isMe(pres.act)) {
// Message is sent by the current user.
assignRead(topic, pres.seq);
}
topic.setTouched(new Date());
break;
case ACS: // access mode changed
if (pres.tgt == null && topic.updateAccessMode(pres.dacs) && mStore != null) {
// tgt is null means permissions are for the current user.
mStore.topicUpdate(topic);
}
break;
case UA: // user agent changed
topic.setLastSeen(new Date(), pres.ua);
break;
case RECV: // user's other session marked some messages as received
assignRecv(topic, pres.seq);
break;
case READ: // user's other session marked some messages as read
assignRead(topic, pres.seq);
break;
case DEL: // messages deleted
// TODO(gene): add handling for del
break;
case GONE:
// If topic is unknown (==null), then we don't care to unregister it.
if (topic.isDeleted()) {
mTinode.stopTrackingTopic(pres.src);
topic.expunge( true);
} else {
topic.expunge(false);
}
break;
}
} else {
switch (what) {
case ACS:
Acs acs = new Acs();
acs.update(pres.dacs);
if (acs.isModeDefined()) {
getMeta(getMetaGetBuilder().withSub(pres.src).build());
} else {
Log.d(TAG, "Unexpected access mode in presence: '" + pres.dacs.want + "'/'" + pres.dacs.given + "'");
}
break;
case TAGS:
// Tags in 'me' topic updated.
getMeta(getMetaGetBuilder().withTags().build());
break;
default:
Log.d(TAG, "Topic not found in me.routePres: " + pres.what + " in " + pres.src);
break;
}
}
}
if (what == MsgServerPres.What.GONE) {
mMeNotifier.notifySubsUpdated();
}
mMeNotifier.notifyPres(pres);
}
@Override
protected void routeInfo(MsgServerInfo info) {
if (info.src == null) {
return;
}
switch (info.what) {
case Tinode.NOTE_KP:
case Tinode.NOTE_REC_AUDIO:
case Tinode.NOTE_REC_VIDEO:
case Tinode.NOTE_CALL:
break;
case Tinode.NOTE_RECV:
case Tinode.NOTE_READ:
Topic topic = mTinode.getTopic(info.src);
if (topic != null) {
topic.setReadRecvByRemote(info.from, info.what, info.seq);
}
// If this is an update from the current user, update the contact with the new count too.
if (mTinode.isMe(info.from)) {
setMsgReadRecv(info.src, info.what, info.seq);
}
break;
default:
// Unknown notification ignored.
}
mMeNotifier.notifyInfo(info);
}
private void assignRead(Topic topic, int seq) {
if (topic.getRead() < seq) {
topic.setRead(seq);
if (mStore != null) {
mStore.setRead(topic, seq);
}
assignRecv(topic, topic.getRead());
}
}
private void assignRecv(Topic topic, int seq) {
if (topic.getRecv() < seq) {
topic.setRecv(seq);
if (mStore != null) {
mStore.setRecv(topic, seq);
}
}
}
void setMsgReadRecv(String topicName, String what, int seq) {
if (seq > 0) {
final Topic topic = mTinode.getTopic(topicName);
if (topic == null) {
return;
}
switch (what) {
case Tinode.NOTE_RECV:
assignRecv(topic, seq);
break;
case Tinode.NOTE_READ:
assignRead(topic, seq);
break;
default:
}
}
mMeNotifier.notifyContUpdated(topicName);
}
@Override
protected void topicLeft(boolean unsub, int code, String reason) {
super.topicLeft(unsub, code, reason);
Collection<Topic> topics = mTinode.getTopics();
if (topics != null) {
for (Topic t : topics) {
t.setOnline(false);
}
}
}
public static class MeListener<DP> implements Listener<DP,PrivateType,DP,PrivateType> {
/** Called by MeTopic when credentials are updated */
public void onCredUpdated(Credential[] cred) {}
}
public static class MeNotifier<DP>
extends Topic.ListenerNotifier<Listener<DP,PrivateType,DP,PrivateType>, DP,PrivateType,DP,PrivateType> {
MeNotifier(List<Listener<DP, PrivateType,DP,PrivateType>> initialListeners) {
super(initialListeners);
}
public void notifyCredUpdated(Credential[] cred) {
for (Listener<DP, PrivateType,DP,PrivateType> l : snapshot()) {
if (l instanceof MeListener) {
((MeListener<DP>)l).onCredUpdated(cred);
}
}
}
}
@Override
public MetaGetBuilder getMetaGetBuilder() {
return new MetaGetBuilder(this);
}
public static class MetaGetBuilder extends Topic.MetaGetBuilder {
MetaGetBuilder(MeTopic parent) {
super(parent);
}
public MetaGetBuilder withCred() {
meta.setCred();
return this;
}
}
}

View File

@@ -0,0 +1,23 @@
package co.tinode.tinodesdk;
/**
* Exception generated in response to a packet containing an error code.
*/
public class NotConnectedException extends IllegalStateException {
public NotConnectedException() {
this((Throwable) null);
}
public NotConnectedException(String s) {
this(s, null);
}
public NotConnectedException(String message, Throwable cause) {
super(message, cause);
}
public NotConnectedException(Throwable cause) {
this("Not connected", cause);
}
}

View File

@@ -0,0 +1,7 @@
package co.tinode.tinodesdk;
/**
* Attempt to interact with a topic without subscribing first
*/
public class NotSubscribedException extends IllegalStateException {
}

View File

@@ -0,0 +1,7 @@
package co.tinode.tinodesdk;
/**
* Attempt to modify a topic which exists only locally.
*/
public class NotSynchronizedException extends IllegalStateException {
}

View File

@@ -0,0 +1,417 @@
package co.tinode.tinodesdk;
import android.util.Log;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
/**
* A very simple thanable promise. It has no facility for execution. It can only be
* resolved/rejected externally by calling resolve/reject. Once resolved/rejected it will call
* listener's onSuccess/onFailure. Depending on results returned or thrown by the handler, it will
* update the next promise in chain: will either resolve/reject it immediately, or make it
* resolve/reject together with the promise returned by the handler.
* <p>
* Usage:
* <p>
* Create a PromisedReply P1, assign onSuccess/onFailure listeners by calling thenApply. thenApply returns
* another P2 promise (mNextPromise), which can then be assigned its own listeners.
* <p>
* Alternatively, one can use a blocking call getResult. It will block until the promise is either
* resolved or rejected.
* <p>
* The promise can be created in either WAITING or RESOLVED state by using an appropriate constructor.
* <p>
* The onSuccess/onFailure handlers will be called:
* <p>
* a. Called at the time of resolution when P1 is resolved through P1.resolve(T) if at the time of
* calling thenApply the promise is in WAITING state,
* b. Called immediately on thenApply if at the time of calling thenApply the promise is already
* in RESOLVED or REJECTED state,
* <p>
* thenApply creates and returns a promise P2 which will be resolved/rejected in the following
* manner:
* <p>
* A. If P1 is resolved:
* 1. If P1.onSuccess returns a resolved promise P3, P2 is resolved immediately on
* return from onSuccess using the result from P3.
* 2. If P1.onSuccess returns a rejected promise P3, P2 is rejected immediately on
* return from onSuccess using the throwable from P3.
* 2. If P1.onSuccess returns null, P2 is resolved immediately using result from P1.
* 3. If P1.onSuccess returns an unresolved promise P3, P2 is resolved together with P3.
* 4. If P1.onSuccess throws an exception, P2 is rejected immediately on catching the exception.
* 5. If P1.onSuccess is null, P2 is resolved immediately using result from P1.
* <p>
* B. If P1 is rejected:
* 1. If P1.onFailure returns a resolved promise P3, P2 is resolved immediately on return from
* onFailure using the result from P3.
* 2. If P1.onFailure returns null, P2 is resolved immediately using null as a result.
* 3. If P1.onFailure returns an unresolved promise P3, P2 is resolved together with P3.
* 4. If P1.onFailure throws an exception, P2 is rejected immediately on catching the exception.
* 5. If P1.onFailure is null, P2 is rejected immediately using the throwable from P1.
* 5.1 If P2.onFailure is null, and P2.mNextPromise is null, an exception is re-thrown.
*
*/
public class PromisedReply<T> {
private static final String TAG = "PromisedReply";
private enum State {WAITING, RESOLVED, REJECTED}
private T mResult = null;
private Exception mException = null;
private volatile State mState = State.WAITING;
private SuccessListener<T> mSuccess = null;
private FailureListener<T> mFailure = null;
private PromisedReply<T> mNextPromise = null;
private final CountDownLatch mDoneSignal;
/**
* Create promise in a WAITING state.
*/
public PromisedReply() {
mDoneSignal = new CountDownLatch(1);
}
/**
* Create a promise in a RESOLVED state
*
* @param result result used for resolution of the promise.
*/
public PromisedReply(T result) {
mResult = result;
mState = State.RESOLVED;
mDoneSignal = new CountDownLatch(0);
}
/**
* Create a promise in a REJECTED state
*
* @param err Exception used for rejecting the promise.
*/
public <E extends Exception> PromisedReply(E err) {
mException = err;
mState = State.REJECTED;
mDoneSignal = new CountDownLatch(0);
}
/**
* Returns a new PromisedReply that is completed when all of the given PromisedReply complete.
* It is rejected if any one is rejected. If resolved, the result is an array or values returned by each input promise.
* If rejected, the result if the exception which rejected one of the input promises.
*
* @param waitFor promises to wait for.
* @return PromisedReply which is resolved when all inputs are resolved or rejected when any one is rejected.
*/
public static <T> PromisedReply<T[]> allOf(PromisedReply<T>[] waitFor) {
final PromisedReply<T[]> done = new PromisedReply<>();
// Create a separate thread and wait for all promises to resolve.
new Thread(() -> {
for (PromisedReply p : waitFor) {
if (p != null) {
try {
p.mDoneSignal.await();
if (p.mState == State.REJECTED) {
done.reject(p.mException);
}
} catch (InterruptedException ex) {
try {
done.reject(ex);
} catch (Exception ignored) {}
return;
} catch (Exception ignored) {
return;
}
}
}
ArrayList<T> result = new ArrayList<>();
for (PromisedReply<T> p : waitFor) {
if (p != null) {
result.add(p.mResult);
} else {
result.add(null);
}
}
// If it throws then nothing we can do about it.
try {
// noinspection unchecked
done.resolve((T[]) result.toArray());
} catch (Exception ignored) {}
}).start();
return done;
}
/**
* Call SuccessListener.onSuccess or FailureListener.onFailure when the
* promise is resolved or rejected. The call will happen on the thread which
* called resolve() or reject().
*
* @param success called when the promise is resolved
* @param failure called when the promise is rejected
* @return promise for chaining
*/
public PromisedReply<T> thenApply(SuccessListener<T> success, FailureListener<T> failure) {
synchronized (this) {
if (mNextPromise != null) {
throw new IllegalStateException("Multiple calls to thenApply are not supported");
}
mSuccess = success;
mFailure = failure;
mNextPromise = new PromisedReply<>();
try {
switch (mState) {
case RESOLVED:
callOnSuccess(mResult);
break;
case REJECTED:
callOnFailure(mException);
break;
case WAITING:
break;
}
} catch (Exception e) {
mNextPromise = new PromisedReply<>(e);
}
return mNextPromise;
}
}
/**
* Calls SuccessListener.onSuccess when the promise is resolved. The call will happen on the
* thread which called resolve().
*
* @param success called when the promise is resolved
* @return promise for chaining
*/
public PromisedReply<T> thenApply(SuccessListener<T> success) {
return thenApply(success, null);
}
/**
* Call onFailure when the promise is rejected. The call will happen on the
* thread which called reject()
*
* @param failure called when the promise is rejected
* @return promise for chaining
*/
public PromisedReply<T> thenCatch(FailureListener<T> failure) {
return thenApply(null, failure);
}
/**
* Call FinalListener.onFinally when the promise is completed. The call will happen on the
* thread which completed the promise: called either resolve() or reject().
*
* @param finished called when the promise is completed either way.
*/
public void thenFinally(final FinalListener finished) {
thenApply(new SuccessListener<>() {
@Override
public PromisedReply<T> onSuccess(T result) {
finished.onFinally();
return null;
}
}, new FailureListener<>() {
@Override
public <E extends Exception> PromisedReply<T> onFailure(E err) {
finished.onFinally();
return null;
}
});
}
@SuppressWarnings("WeakerAccess")
public boolean isResolved() {
return mState == State.RESOLVED;
}
@SuppressWarnings("unused")
public boolean isRejected() {
return mState == State.REJECTED;
}
@SuppressWarnings({"WeakerAccess"})
public boolean isDone() {
return mState == State.RESOLVED || mState == State.REJECTED;
}
/**
* Make this promise resolved.
*
* @param result results of resolution.
* @throws Exception if anything goes wrong during resolution.
*/
public void resolve(final T result) throws Exception {
synchronized (this) {
if (mState == State.WAITING) {
mState = State.RESOLVED;
mResult = result;
try {
callOnSuccess(result);
} finally {
mDoneSignal.countDown();
}
} else {
mDoneSignal.countDown();
throw new IllegalStateException("Promise is already completed");
}
}
}
/**
* Make this promise rejected.
*
* @param err reason for rejecting this promise.
* @throws Exception if anything goes wrong during rejection.
*/
public void reject(final Exception err) throws Exception {
synchronized (this) {
if (mState == State.WAITING) {
mState = State.REJECTED;
mException = err;
try {
callOnFailure(err);
} finally {
mDoneSignal.countDown();
}
} else {
mDoneSignal.countDown();
throw new IllegalStateException("Promise is already completed");
}
}
}
/**
* Wait for promise resolution.
*
* @return true if the promise was resolved, false otherwise
* @throws InterruptedException if waiting was interrupted
*/
public boolean waitResult() throws InterruptedException {
// Wait for the promise to resolve
mDoneSignal.await();
return isResolved();
}
/**
* A blocking call which returns the result of the execution. It will return
* <b>after</b> thenApply is called. It can be safely called multiple times on
* the same instance.
*
* @return result of the execution (what was passed to {@link #resolve(Object)})
* @throws Exception if the promise was rejected or waiting was interrupted.
*/
public T getResult() throws Exception {
// Wait for the promise to resolve
mDoneSignal.await();
return switch (mState) {
case RESOLVED -> mResult;
case REJECTED -> throw mException;
default -> throw new IllegalStateException("Promise cannot be in WAITING state");
};
}
private void callOnSuccess(final T result) throws Exception {
PromisedReply<T> ret;
try {
ret = (mSuccess != null ? mSuccess.onSuccess(result) : null);
} catch (Exception e) {
handleFailure(e);
return;
}
// If it throws, let it fly.
handleSuccess(ret);
}
private void callOnFailure(final Exception err) throws Exception {
if (mFailure != null) {
// Try to recover
try {
handleSuccess(mFailure.onFailure(err));
} catch (Exception ex) {
handleFailure(ex);
}
} else {
// Pass to the next handler
handleFailure(err);
}
}
private void handleSuccess(PromisedReply<T> ret) throws Exception {
if (mNextPromise == null) {
if (ret != null && ret.mState == State.REJECTED) {
throw ret.mException;
}
return;
}
if (ret == null) {
mNextPromise.resolve(mResult);
} else if (ret.mState == State.RESOLVED) {
mNextPromise.resolve(ret.mResult);
} else if (ret.mState == State.REJECTED) {
mNextPromise.reject(ret.mException);
} else {
// Next promise will be called when ret is completed
ret.insertNextPromise(mNextPromise);
}
}
private void handleFailure(Exception e) throws Exception {
if (mNextPromise != null) {
mNextPromise.reject(e);
} else {
throw e;
}
}
private void insertNextPromise(PromisedReply<T> next) {
synchronized (this) {
if (mNextPromise != null) {
next.insertNextPromise(mNextPromise);
}
mNextPromise = next;
}
}
public static abstract class SuccessListener<U> {
/**
* Callback to execute when the promise is successfully resolved.
*
* @param result result of the call.
* @return new promise to pass to the next handler in the chain or null to use the same result.
* @throws Exception thrown if handler want to call the next failure handler in chain.
*/
public abstract PromisedReply<U> onSuccess(U result) throws Exception;
}
public static abstract class FailureListener<U> {
/**
* Callback to execute when the promise is rejected.
*
* @param err Exception which caused promise to fail.
* @return new promise to pass to the next success handler in the chain.
* @throws Exception thrown if handler want to call the next failure handler in chain.
*/
public abstract <E extends Exception> PromisedReply<U> onFailure(E err) throws Exception;
}
public static abstract class FinalListener {
public abstract void onFinally();
}
}

View File

@@ -0,0 +1,38 @@
package co.tinode.tinodesdk;
import org.jetbrains.annotations.NotNull;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
/**
* Augmented SimpleDateFormat for handling optional milliseconds in RFC3339 timestamps.
*/
public class RFC3339Format extends SimpleDateFormat {
private final SimpleDateFormat mShortDate;
public RFC3339Format() {
super("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",Locale.US);
mShortDate = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
setTimeZone(TimeZone.getTimeZone("UTC"));
mShortDate.setTimeZone(TimeZone.getTimeZone("UTC"));
}
// Server may generate timestamps without milliseconds.
// SDF cannot parse optional millis. Must treat them explicitly.
@Override
public Date parse(@NotNull String text) throws ParseException {
Date date;
try {
date = super.parse(text);
} catch (ParseException ignore) {
date = mShortDate.parse(text);
}
return date;
}
}

View File

@@ -0,0 +1,34 @@
package co.tinode.tinodesdk;
/**
* Exception generated in response to a packet containing an error code.
*/
public class ServerResponseException extends Exception {
private final int code;
private final String reason;
ServerResponseException(int code, String text, String reason) {
super(text);
this.code = code;
this.reason = reason;
}
ServerResponseException(int code, String text) {
super(text);
this.code = code;
this.reason = text;
}
@Override
public String getMessage() {
return super.getMessage() + " (" + code + ")";
}
public int getCode() {
return code;
}
public String getReason() {
return reason;
}
}

View File

@@ -0,0 +1,288 @@
package co.tinode.tinodesdk;
import java.io.Closeable;
import java.util.Collection;
import java.util.Date;
import java.util.Iterator;
import java.util.Map;
import co.tinode.tinodesdk.model.Drafty;
import co.tinode.tinodesdk.model.MsgRange;
import co.tinode.tinodesdk.model.MsgServerData;
import co.tinode.tinodesdk.model.Subscription;
/**
* Interface for implementing persistence.
*/
public interface Storage {
String getMyUid();
// Update UID and clear unvalidated credentials.
void setMyUid(String uid, String hostURI);
// Server-requested validation credentials for the currently active account.
void updateCredentials(String[] credRequired);
// Delete given account.
void deleteAccount(String uid);
String getServerURI();
String getDeviceToken();
void saveDeviceToken(String token);
void logout();
// Server time minus local time
void setTimeAdjustment(long adjustment);
boolean isReady();
// Fetch all topics
Topic[] topicGetAll(Tinode tinode);
// Fetch one topic by name
Topic topicGet(Tinode tinode, String name);
// Add new topic
@SuppressWarnings("UnusedReturnValue")
long topicAdd(Topic topic);
/** Incoming change to topic description: the already mutated topic in memory is synchronized to DB */
@SuppressWarnings("UnusedReturnValue")
boolean topicUpdate(Topic topic);
/** Delete topic */
@SuppressWarnings("UnusedReturnValue")
boolean topicDelete(Topic topic, boolean hard);
/** Add subscription in a generic topic. The subscription is received from the server. */
@SuppressWarnings("UnusedReturnValue")
long subAdd(Topic topic, Subscription sub);
/** Update subscription in a generic topic */
@SuppressWarnings("UnusedReturnValue")
boolean subUpdate(Topic topic, Subscription sub);
/** Add a new subscriber to topic. The new subscriber is being added locally. */
@SuppressWarnings("UnusedReturnValue")
long subNew(Topic topic, Subscription sub);
/** Delete existing subscription */
@SuppressWarnings("UnusedReturnValue")
boolean subDelete(Topic topic, Subscription sub);
/** Get a list o topic subscriptions from DB. */
Collection<Subscription> getSubscriptions(Topic topic);
/** Read user description */
User userGet(String uid);
/** Insert new user */
@SuppressWarnings("UnusedReturnValue")
long userAdd(User user);
/** Update existing user */
@SuppressWarnings("UnusedReturnValue")
boolean userUpdate(User user);
/**
* Message received from the server.
*/
Message msgReceived(Topic topic, Subscription sub, MsgServerData msg);
/**
* Save message to DB as "sending".
*
* @param topic topic which sent the message
* @param data message data to save
* @param head message headers
* @return database ID of the message suitable for use in
* {@link #msgDelivered(Topic topic, long id, Date timestamp, int seq)}
*/
Message msgSend(Topic topic, Drafty data, Map<String, Object> head);
/**
* Save message to database as a draft. Draft will not be sent to server until it status changes.
*
* @param topic topic which sent the message
* @param data message data to save
* @param head message headers
* @return database ID of the message suitable for use in
* {@link #msgDelivered(Topic topic, long id, Date timestamp, int seq)}
*/
Message msgDraft(Topic topic, Drafty data, Map<String, Object> head);
/**
* Update message draft content without
*
* @param topic topic which sent the message
* @param dbMessageId database ID of the message.
* @param data updated content of the message. Must not be null.
* @return true on success, false otherwise
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgDraftUpdate(Topic topic, long dbMessageId, Drafty data);
/**
* Message is ready to be sent to the server.
*
* @param topic topic which sent the message
* @param dbMessageId database ID of the message.
* @param data updated content of the message. If null only status is updated.
* @return true on success, false otherwise
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgReady(Topic topic, long dbMessageId, Drafty data);
/**
* Message is being sent to the server.
* @param topic topic which sent the message
* @param dbMessageId database ID of the message.
* @param sync true when the sync started, false when it's finished unsuccessfully.
* @return true on success, false otherwise
*
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgSyncing(Topic topic, long dbMessageId, boolean sync);
/**
* Failed to form or send message.
*
* @param topic topic which sent the message
* @param dbMessageId database ID of the message.
* @return true on success, false otherwise
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgFailed(Topic topic, long dbMessageId);
/**
* Delete all failed messages in the given topis.
*
* @param topic topic which sent the message
* @return true on success, false otherwise
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgPruneFailed(Topic topic);
/**
* Remove message by database id.
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgDiscard(Topic topic, long dbMessageId);
/**
* Remove message by seq ID.
*/
@SuppressWarnings("UnusedReturnValue")
boolean msgDiscardSeq(Topic topic, int seq);
/**
* Message delivered to the server and received a real seq ID.
*
* @param topic topic which sent the message.
* @param dbMessageId database ID of the message.
* @param timestamp server timestamp.
* @param seq server-issued message seqId.
* @return true on success, false otherwise *
*/
boolean msgDelivered(Topic topic, long dbMessageId, Date timestamp, int seq);
/** Mark messages for deletion by range */
@SuppressWarnings("UnusedReturnValue")
boolean msgMarkToDelete(Topic topic, int fromId, int toId, boolean markAsHard);
/** Mark messages for deletion by seq ID list */
@SuppressWarnings("UnusedReturnValue")
boolean msgMarkToDelete(Topic topic, MsgRange[] ranges, boolean markAsHard);
/** Delete messages */
@SuppressWarnings("UnusedReturnValue")
boolean msgDelete(Topic topic, int delId, int fromId, int toId);
/** Delete messages */
@SuppressWarnings("UnusedReturnValue")
boolean msgDelete(Topic topic, int delId, MsgRange[] ranges);
/** Set recv value for a given subscriber */
@SuppressWarnings("UnusedReturnValue")
boolean msgRecvByRemote(Subscription sub, int recv);
/** Set read value for a given subscriber */
@SuppressWarnings("UnusedReturnValue")
boolean msgReadByRemote(Subscription sub, int read);
/**
* Returns message ranges present in DB.
*
* @param topic topic to query.
* @param ranges message ranges to test for presence in the local cache.
* @return those message ranges which are present in the local cache.
*/
MsgRange[] msgIsCached(Topic topic, MsgRange[] ranges);
/** Get seq IDs of the stored messages as a MsgRange, inclusive-exclusive [low, hi) */
MsgRange getCachedMessagesRange(Topic topic);
/**
* Get the ranges of the messages missing in cache, inclusive-exclusive [low, hi).
* Returns empty array if all messages are present or no messages are found.
*/
MsgRange[] getMissingRanges(Topic topic, int startFrom, int pageSize, boolean newer);
/** Local user reported messages as read */
@SuppressWarnings("UnusedReturnValue")
boolean setRead(Topic topic, int read);
/** Local user reported messages as received */
@SuppressWarnings("UnusedReturnValue")
boolean setRecv(Topic topic, int recv);
/** Retrieve a single message by database id */
<T extends Message> T getMessageById(long dbMessageId);
/**
* Retrieve a single message preview by database id.
*/
<T extends Message> T getMessagePreviewById(long dbMessageId);
/**
* Get seq IDs of up to limit versions of the edited message with the given ID.
* @param topic topic which sent the message.
* @param seq ID of the edited message to get versions of.
* @param limit the count of latest versions to get or all if limit is zero.
* @return array of seq ID of edits ordered from newest to oldest.
*/
int[] getAllMsgVersions(Topic topic, int seq, int limit);
/**
* Get the latest message in each topic. Caller must close the result after use.
*/
<T extends Iterator<Message> & Closeable> T getLatestMessagePreviews();
/** Get a list of unsent messages. Close the result after use. */
<T extends Iterator<Message> & Closeable> T getQueuedMessages(Topic topic);
/**
* Get a list of pending delete message ranges.
* @param topic topic where the messages were deleted.
* @param hard set to <b>true</b> to fetch hard-deleted messages, soft-deleted otherwise.
*/
MsgRange[] getQueuedMessageDeletes(Topic topic, boolean hard);
/**
* Retrieve a single message by topic and seq ID.
*/
<T extends Message> T getMessageBySeq(Topic topic, int seq);
interface Message {
String getTopic();
/** Get message headers */
Map<String, Object> getHead();
Object getHeader(String key);
String getStringHeader(String key);
Integer getIntHeader(String key);
/** Get message payload */
Drafty getContent();
/** Set message payload */
void setContent(Drafty content);
/** Get current message unique ID (database ID) */
long getDbId();
/** Get Tinode seq Id of the message (different from database ID */
int getSeqId();
/** Get delivery status */
int getStatus();
boolean isMine();
boolean isPending();
boolean isReady();
boolean isDeleted();
boolean isDeleted(boolean hard);
boolean isSynced();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
package co.tinode.tinodesdk;
import java.util.Date;
import co.tinode.tinodesdk.model.Description;
import co.tinode.tinodesdk.model.Mergeable;
import co.tinode.tinodesdk.model.Subscription;
/**
* Information about specific user
*/
public class User<P> implements LocalData {
public Date updated;
public String uid;
public P pub;
private Payload mLocal = null;
public User() {
}
public User(String uid) {
this.uid = uid;
}
public User(Subscription<P,?> sub) {
if (sub.user != null && !sub.user.isEmpty()) {
uid = sub.user;
updated = sub.updated;
pub = sub.pub;
} else {
throw new IllegalArgumentException();
}
}
public User(String uid, Description<P,?> desc) {
this.uid = uid;
updated = desc.updated;
try {
pub = desc.pub;
} catch (ClassCastException ignored) {}
}
private boolean mergePub(P pub) {
boolean changed = false;
if (pub != null) {
try {
if (Tinode.isNull(pub)) {
this.pub = null;
changed = true;
} else if (this.pub != null && (this.pub instanceof Mergeable)) {
changed = ((Mergeable) this.pub).merge((Mergeable) pub);
} else {
this.pub = pub;
changed = true;
}
} catch (ClassCastException ignored) { }
}
return changed;
}
public boolean merge(User<P> user) {
boolean changed = false;
if ((user.updated != null) && (updated == null || updated.before(user.updated))) {
updated = user.updated;
changed = mergePub(user.pub);
} else if (pub == null && user.pub != null) {
pub = user.pub;
changed = true;
}
return changed;
}
public boolean merge(Subscription<P,?> sub) {
boolean changed = false;
if ((sub.updated != null) && (updated == null || updated.before(sub.updated))) {
updated = sub.updated;
changed = mergePub(sub.pub);
} else if (pub == null && sub.pub != null) {
pub = sub.pub;
changed = true;
}
return changed;
}
public boolean merge(Description<P,?> desc) {
boolean changed = false;
if ((desc.updated != null) && (updated == null || updated.before(desc.updated))) {
updated = desc.updated;
changed = mergePub(desc.pub);
} else if (pub == null && desc.pub != null) {
pub = desc.pub;
changed = true;
}
return changed;
}
@Override
public void setLocal(Payload value) {
mLocal = value;
}
@Override
public Payload getLocal() {
return mLocal;
}
}

View File

@@ -0,0 +1,23 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
public class AccessChange implements Serializable {
public String want;
public String given;
public AccessChange() {
}
public AccessChange(String want, String given) {
this.want = want;
this.given = given;
}
@SuppressWarnings("NullableProblems")
@Override
public String toString() {
return "{\"given\":" + (given != null ? " \"" + given + "\"" : " null") +
", \"want\":" + (want != null ? " \"" + want + "\"" : " null}");
}
}

View File

@@ -0,0 +1,384 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.Map;
/**
* Access mode.
*/
public class Acs implements Serializable {
public enum Side {
MODE(0), WANT(1), GIVEN(2);
private final int val;
Side(int val) {
this.val = val;
}
public int val() {
return val;
}
}
AcsHelper given = null;
AcsHelper want = null;
AcsHelper mode = null;
public Acs() {
assign(null, null, null);
}
public Acs(String g, String w) {
assign(g, w, null);
}
public Acs(String g, String w, String m) {
assign(g, w, m);
}
public Acs(Acs am) {
if (am != null) {
given = am.given != null ? new AcsHelper(am.given) : null;
want = am.want != null ? new AcsHelper(am.want) : null;
mode = am.mode != null ? new AcsHelper(am.mode) : AcsHelper.and(want, given);
}
}
public Acs(Map<String,String> am) {
if (am != null) {
assign(am.get("given"), am.get("want"), am.get("mode"));
}
}
public Acs(AccessChange ac) {
if (ac != null) {
boolean change = false;
if (ac.given != null) {
if (given == null) {
given = new AcsHelper();
}
change = given.update(ac.given);
}
if (ac.want != null) {
if (want == null) {
want = new AcsHelper();
}
change = change || want.update(ac.want);
}
if (change) {
mode = AcsHelper.and(want, given);
}
}
}
private void assign(String g, String w, String m) {
this.given = g != null ? new AcsHelper(g) : null;
this.want = w != null ? new AcsHelper(w) : null;
this.mode = m != null ? new AcsHelper(m) : AcsHelper.and(want, given);
}
public void setMode(String m) {
mode = m != null ? new AcsHelper(m) : null;
}
public String getMode() {
return mode != null ? mode.toString() : null;
}
public AcsHelper getModeHelper() {
return new AcsHelper(mode);
}
public void setGiven(String g) {
given = g != null ? new AcsHelper(g) : null;
}
public String getGiven() {
return given != null ? given.toString() : null;
}
public AcsHelper getGivenHelper() {
return new AcsHelper(given);
}
public void setWant(String w) {
want = w != null ? new AcsHelper(w) : null;
}
public String getWant() {
return want != null ? want.toString() : null;
}
public AcsHelper getWantHelper() {
return new AcsHelper(want);
}
public boolean merge(Acs am) {
int change = 0;
if (am != null && !equals(am)) {
if (am.given != null) {
if (given == null) {
given = new AcsHelper();
}
change += given.merge(am.given) ? 1 : 0;
}
if (am.want != null) {
if (want == null) {
want = new AcsHelper();
}
change += want.merge(am.want) ? 1 : 0;
}
if (am.mode != null) {
if (mode == null) {
mode = new AcsHelper();
}
change += mode.merge(am.mode) ? 1 : 0;
} else if (change > 0) {
AcsHelper m2 = AcsHelper.and(want, given);
if (m2 != null && !m2.equals(mode)) {
change ++;
mode = m2;
}
}
}
return change > 0;
}
public boolean merge(Map<String,String> am) {
int change = 0;
if (am != null) {
if (am.get("given") != null) {
change += given.merge(new AcsHelper(am.get("given"))) ? 1 : 0;
}
if (am.get("want") != null) {
change += want.merge(new AcsHelper(am.get("want"))) ? 1 : 0;
}
if (am.get("mode") != null) {
change += mode.merge(new AcsHelper(am.get("mode"))) ? 1 : 0;
} else if (change > 0) {
AcsHelper m2 = AcsHelper.and(want, given);
if (m2 != null && !m2.equals(mode)) {
change ++;
mode = m2;
}
}
}
return change > 0;
}
public boolean update(AccessChange ac) {
int change = 0;
if (ac != null) {
try {
if (ac.given != null) {
if (given == null) {
given = new AcsHelper();
}
change += given.update(ac.given) ? 1 : 0;
}
if (ac.want != null) {
if (want == null) {
want = new AcsHelper();
}
change += want.update(ac.want) ? 1 : 0;
}
} catch (IllegalArgumentException ignore) {}
if (change > 0) {
AcsHelper m2 = AcsHelper.and(want, given);
if (m2 != null && !m2.equals(mode)) {
mode = m2;
}
}
}
return change > 0;
}
/**
* Compare this Acs with another.
* @param am Acs instance to compare to.
* @return true if am represents the same access rights, false otherwise.
*/
public boolean equals(Acs am) {
return (am != null) &&
((mode == null && am.mode == null) || (mode != null && mode.equals(am.mode))) &&
((want == null && am.want == null) || (want != null && want.equals(am.want))) &&
((given == null && am.given == null) || (given != null && given.equals(am.given)));
}
/**
* Check if mode is NONE: no flags are set.
* @return true if no flags are set.
*/
public boolean isNone() {
return mode != null && mode.isNone();
}
/**
* Check if mode Reader (R) flag is set.
* @return true if flag is set.
*/
public boolean isReader() {
return mode != null && mode.isReader();
}
/**
* Check if Reader (R) flag is set for the given side.
* @return true if flag is set.
*/
public boolean isReader(Side s) {
return switch (s) {
case MODE -> mode != null && mode.isReader();
case WANT -> want != null && want.isReader();
case GIVEN -> given != null && given.isReader();
};
}
/**
* Check if Writer (W) flag is set.
* @return true if flag is set.
*/
public boolean isWriter() {
return mode != null && mode.isWriter();
}
/**
* Check if Presence (P) flag is NOT set.
* @return true if flag is NOT set.
*/
public boolean isMuted() {
return mode != null && mode.isMuted();
}
@JsonIgnore
public Acs setMuted(boolean v) {
if (mode == null) {
mode = new AcsHelper("N");
}
mode.setMuted(v);
return this;
}
/**
* Check if Approver (A) flag is set.
* @return true if flag is set.
*/
public boolean isApprover() {
return mode != null && mode.isApprover();
}
/**
* Check if either Owner (O) or Approver (A) flag is set.
* @return true if flag is set.
*/
public boolean isManager() {
return mode != null && (mode.isApprover() || mode.isOwner());
}
/**
* Check if Sharer (S) flag is set.
* @return true if flag is set.
*/
public boolean isSharer() {
return mode != null && mode.isSharer();
}
/**
* Check if Deleter (D) flag is set.
* @return true if flag is set.
*/
public boolean isDeleter() {
return mode != null && mode.isDeleter();
}
/**
* Check if Owner (O) flag is set.
* @return true if flag is set.
*/
public boolean isOwner() {
return mode != null && mode.isOwner();
}
/**
* Check if Joiner (J) flag is set.
* @return true if flag is set.
*/
public boolean isJoiner() {
return mode != null && mode.isJoiner();
}
/**
* Check if Joiner (J) flag is set for the specified side.
* @param s site to query (mode, want, given).
* @return true if flag is set.
*/
public boolean isJoiner(Side s) {
return switch (s) {
case MODE -> mode != null && mode.isJoiner();
case WANT -> want != null && want.isJoiner();
case GIVEN -> given != null && given.isJoiner();
};
}
/**
* Check if mode is defined.
* @return true if defined.
*/
public boolean isModeDefined() {
return mode != null && mode.isDefined();
}
/**
* Check if given is defined.
* @return true if defined.
*/
public boolean isGivenDefined() {
return given != null && given.isDefined();
}
/**
* Check if want is defined.
* @return true if defined.
*/
public boolean isWantDefined() {
return want != null && want.isDefined();
}
/**
* Check if mode is invalid.
* @return true if invalid.
*/
public boolean isInvalid() {
return mode != null && mode.isInvalid();
}
/**
* Get permissions present in 'want' but missing in 'given'.
* Inverse of {@link Acs#getExcessive}
*
* @return <b>want</b> value.
*/
@JsonIgnore
public AcsHelper getMissing() {
return AcsHelper.diff(want, given);
}
/**
* Get permissions present in 'given' but missing in 'want'.
* Inverse of {@link Acs#getMissing}
*
* @return permissions present in <b>given</b> but missing in <b>want</b>.
*/
@JsonIgnore
public AcsHelper getExcessive() {
return AcsHelper.diff(given, want);
}
@NotNull
@Override
public String toString() {
return "{\"given\":" + (given != null ? " \"" + given + "\"" : " null") +
", \"want\":" + (want != null ? " \"" + want + "\"" : " null") +
", \"mode\":" + (mode != null ? " \"" + mode + "\"}" : " null}");
}
}

View File

@@ -0,0 +1,286 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.StringTokenizer;
/**
* Helper class for access mode parser/generator.
*/
public class AcsHelper implements Serializable {
// User access to topic
private static final int MODE_JOIN = 0x01; // J - join topic
private static final int MODE_READ = 0x02; // R - read broadcasts
private static final int MODE_WRITE = 0x04; // W - publish
private static final int MODE_PRES = 0x08; // P - receive presence notifications
private static final int MODE_APPROVE = 0x10; // A - approve requests
private static final int MODE_SHARE = 0x20; // S - user can invite other people to join (S)
private static final int MODE_DELETE = 0x40; // D - user can hard-delete messages (D), only owner can completely delete
private static final int MODE_OWNER = 0x80; // O - user is the owner (O) - full access
private static final int MODE_NONE = 0; // No access, requests to gain access are processed normally (N)
// Invalid mode to indicate an error
private static final int MODE_INVALID = 0x100000;
private int a;
public AcsHelper() {
a = MODE_NONE;
}
public AcsHelper(String str) {
a = decode(str);
}
public AcsHelper(AcsHelper ah) {
a = ah != null ? ah.a : MODE_INVALID;
}
public AcsHelper(Integer a) {
this.a = a != null ? a : MODE_INVALID;
}
@NotNull
@Override
public String toString() {
return encode(a);
}
public boolean update(String umode) {
int old = a;
a = update(a, umode);
return a != old;
}
@Override
public boolean equals(Object o) {
if (o == null) {
return false;
}
if (o == this) {
return true;
}
if (!(o instanceof AcsHelper ah)) {
return false;
}
return a == ah.a;
}
public boolean equals(String s) {
return a == decode(s);
}
public boolean isNone() {
return a == MODE_NONE;
}
public boolean isReader() {
return (a & MODE_READ) != 0;
}
public boolean isWriter() {
return (a & MODE_WRITE) != 0;
}
public boolean isMuted() {
return (a & MODE_PRES) == 0;
}
@JsonIgnore
public void setMuted(boolean v) {
if (a == MODE_INVALID) {
a = MODE_NONE;
}
a = !v ? (a | MODE_PRES) : (a & ~MODE_PRES);
}
public boolean isApprover() {
return (a & MODE_APPROVE) != 0;
}
public boolean isSharer() {
return (a & MODE_SHARE) != 0;
}
public boolean isDeleter() {
return (a & MODE_DELETE) != 0;
}
public boolean isOwner() {
return (a & MODE_OWNER) != 0;
}
public boolean isJoiner() {
return (a & MODE_JOIN) != 0;
}
public boolean isDefined() {
return a != MODE_INVALID;
}
public boolean isInvalid() {
return a == MODE_INVALID;
}
private static int decode(String mode) {
if (mode == null || mode.isEmpty()) {
return MODE_INVALID;
}
int m0 = MODE_NONE;
for (char c : mode.toCharArray()) {
switch (c) {
case 'J':
case 'j':
m0 |= MODE_JOIN;
continue;
case 'R':
case 'r':
m0 |= MODE_READ;
continue;
case 'W':
case 'w':
m0 |= MODE_WRITE;
continue;
case 'A':
case 'a':
m0 |= MODE_APPROVE;
continue;
case 'S':
case 's':
m0 |= MODE_SHARE;
continue;
case 'D':
case 'd':
m0 |= MODE_DELETE;
continue;
case 'P':
case 'p':
m0 |= MODE_PRES;
continue;
case 'O':
case 'o':
m0 |= MODE_OWNER;
continue;
case 'N':
case 'n':
return MODE_NONE;
default:
return MODE_INVALID;
}
}
return m0;
}
private static String encode(Integer val) {
// Need to distinguish between "not set" and "no access"
if (val == null || val == MODE_INVALID) {
return "";
}
if (val == MODE_NONE) {
return "N";
}
StringBuilder res = new StringBuilder(6);
char[] modes = new char[]{'J', 'R', 'W', 'P', 'A', 'S', 'D', 'O'};
for (int i = 0; i < modes.length; i++) {
if ((val & (1 << i)) != 0) {
res.append(modes[i]);
}
}
return res.toString();
}
/**
* Apply changes, defined as a string, to the given internal representation.
*
* @param val value to change.
* @param umode change to the value, '+' or '-' followed by the letter(s) being set or unset,
* or an explicit new value: "+JS-WR" or just "JSA"
* @return updated value.
*/
private static int update(int val, String umode) {
if (umode == null || umode.isEmpty()) {
return val;
}
int m0;
char action = umode.charAt(0);
if (action == '+' || action == '-') {
int val0 = val;
StringTokenizer parts = new StringTokenizer(umode, "-+", true);
while (parts.hasMoreTokens()) {
action = parts.nextToken().charAt(0);
if (parts.hasMoreTokens()) {
m0 = decode(parts.nextToken());
} else {
break;
}
if (m0 == MODE_INVALID) {
throw new IllegalArgumentException();
}
if (m0 == MODE_NONE) {
continue;
}
if (action == '+') {
val0 |= m0;
} else if (action == '-') {
val0 &= ~m0;
}
}
val = val0;
} else {
val = decode(umode);
if (val == MODE_INVALID) {
throw new IllegalArgumentException();
}
}
return val;
}
public boolean merge(AcsHelper ah) {
if (ah != null && ah.a != MODE_INVALID) {
if (ah.a != a) {
a = ah.a;
return true;
}
}
return false;
}
/**
* Bitwise AND between two modes, usually given & want: a1 & a2.
* @param a1 first mode
* @param a2 second mode
* @return {AcsHelper} (a1 & a2)
*/
public static AcsHelper and(AcsHelper a1, AcsHelper a2) {
if (a1 != null && !a1.isInvalid() && a2 != null && !a2.isInvalid()) {
return new AcsHelper(a1.a & a2.a);
}
return null;
}
/**
* Get bits present in a1 but missing in a2: a1 & ~a2.
* @param a1 first mode
* @param a2 second mode
* @return {AcsHelper} (a1 & ~a2)
*/
public static AcsHelper diff(AcsHelper a1, AcsHelper a2) {
if (a1 != null && !a1.isInvalid() && a2 != null && !a2.isInvalid()) {
return new AcsHelper(a1.a & ~a2.a);
}
return null;
}
}

View File

@@ -0,0 +1,93 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.core.Base64Variants;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.StringTokenizer;
/**
* Helper of authentication scheme for account creation.
*/
public record AuthScheme(String scheme, String secret) implements Serializable {
public static final String LOGIN_BASIC = "basic";
public static final String LOGIN_TOKEN = "token";
public static final String LOGIN_RESET = "reset";
public static final String LOGIN_CODE = "code";
@NotNull
@Override
public String toString() {
return scheme + ":" + secret;
}
public static AuthScheme parse(String s) {
if (s != null) {
StringTokenizer st = new StringTokenizer(s, ":");
if (st.countTokens() == 2) {
String scheme = st.nextToken();
if (scheme.contentEquals(LOGIN_BASIC) || scheme.contentEquals(LOGIN_TOKEN)) {
return new AuthScheme(scheme, st.nextToken());
}
} else {
throw new IllegalArgumentException();
}
}
return null;
}
public static String encodeBasicToken(String uname, String password) {
uname = uname == null ? "" : uname;
// Encode string as base64
if (uname.contains(":")) {
throw new IllegalArgumentException("illegal character ':' in user name '" + uname + "'");
}
password = password == null ? "" : password;
return Base64Variants.getDefaultVariant().encode((uname + ":" + password).getBytes(StandardCharsets.UTF_8));
}
public static String encodeResetSecret(String scheme, String method, String value) {
// Join parts using ":" then base64-encode.
if (scheme == null || method == null || value == null) {
throw new IllegalArgumentException("illegal 'null' parameter");
}
if (scheme.contains(":") || method.contains(":") || value.contains(":")) {
throw new IllegalArgumentException("illegal character ':' in parameter");
}
return Base64Variants.getDefaultVariant().encode((scheme + ":" + method + ":" + value)
.getBytes(StandardCharsets.UTF_8));
}
public static String[] decodeBasicToken(String token) {
String basicToken;
// Decode base64 string
basicToken = new String(Base64Variants.getDefaultVariant().decode(token), StandardCharsets.UTF_8);
// Split "login:password" into parts.
int splitAt = basicToken.indexOf(':');
if (splitAt <= 0) {
return null;
}
return new String[]{
basicToken.substring(0, splitAt),
splitAt == basicToken.length() - 1 ? "" : basicToken.substring(splitAt + 1, basicToken.length() - 1)
};
}
public static AuthScheme basicInstance(String login, String password) {
return new AuthScheme(LOGIN_BASIC, encodeBasicToken(login, password));
}
public static AuthScheme tokenInstance(String secret) {
return new AuthScheme(LOGIN_TOKEN, secret);
}
public static AuthScheme codeInstance(String code, String method, String value) {
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
return new AuthScheme(LOGIN_CODE, encodeResetSecret(code, method, value));
}
}

View File

@@ -0,0 +1,70 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Client message:
* <p>
* Hi *MsgClientHi `json:"hi"`
* Acc *MsgClientAcc `json:"acc"`
* Login *MsgClientLogin `json:"login"`
* Sub *MsgClientSub `json:"sub"`
* Leave *MsgClientLeave `json:"leave"`
* Pub *MsgClientPub `json:"pub"`
* Get *MsgClientGet `json:"get"`
* Set *MsgClientSet `json:"set"`
* Del *MsgClientDel `json:"del"`
* Note *MsgClientNote `json:"note"`
*/
@JsonInclude(NON_DEFAULT)
public class ClientMessage<Pu,Pr> implements Serializable {
public MsgClientHi hi;
public MsgClientAcc<Pu,Pr> acc;
public MsgClientLogin login;
public MsgClientSub sub;
public MsgClientLeave leave;
public MsgClientPub pub;
public MsgClientGet get;
public MsgClientSet set;
public MsgClientDel del;
public MsgClientNote note;
// Optional data.
public MsgClientExtra extra;
public ClientMessage() {
}
public ClientMessage(MsgClientHi hi) {
this.hi = hi;
}
public ClientMessage(MsgClientAcc<Pu,Pr> acc) {
this.acc = acc;
}
public ClientMessage(MsgClientLogin login) {
this.login = login;
}
public ClientMessage(MsgClientSub sub) {
this.sub = sub;
}
public ClientMessage(MsgClientLeave leave) {
this.leave = leave;
}
public ClientMessage(MsgClientPub pub) {
this.pub = pub;
}
public ClientMessage(MsgClientGet get) {
this.get = get;
}
public ClientMessage(MsgClientSet set) {
this.set = set;
}
public ClientMessage(MsgClientDel del) {
this.del = del;
}
public ClientMessage(MsgClientNote note) {
this.note = note;
}
}

View File

@@ -0,0 +1,78 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.Serializable;
import java.util.Arrays;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Account credential: email, phone, captcha
*/
@JsonInclude(NON_DEFAULT)
public class Credential implements Comparable<Credential>, Serializable {
public static final String METH_EMAIL = "email";
public static final String METH_PHONE = "tel";
// Confirmation method: email, phone, captcha.
public String meth;
// Credential to be validated, e.g. email or a phone number.
public String val;
// Confirmation response, such as '123456'.
public String resp;
// Confirmation parameters.
public Object params;
// Indicator if credential is validated.
public Boolean done;
public static Credential[] append(@Nullable Credential[] creds, @NotNull Credential c) {
if (creds == null) {
creds = new Credential[1];
} else {
creds = Arrays.copyOf(creds, creds.length + 1);
}
creds[creds.length - 1] = c;
return creds;
}
public Credential() {
}
public Credential(String meth, String val) {
this.meth = meth;
this.val = val;
}
public Credential(String meth, String val, String resp, Object params) {
this(meth, val);
this.resp = resp;
this.params = params;
}
public boolean isDone() {
return done != null && done;
}
@SuppressWarnings("NullableProblems")
@Override
public String toString() {
return meth + ":" + val;
}
@Override
public int compareTo(Credential other) {
int r = meth.compareTo(other.meth);
if (r ==0) {
r = val.compareTo(other.val);
}
if (r == 0) {
r = done.compareTo(other.done);
}
return r;
}
}

View File

@@ -0,0 +1,73 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
import java.util.Objects;
/**
* Class describing default access to topic
*/
public class Defacs implements Serializable {
public AcsHelper auth;
public AcsHelper anon;
public Defacs() {
}
public Defacs(String auth, String anon) {
setAuth(auth);
setAnon(anon);
}
@Override
public boolean equals(Object o) {
if (o == null) {
return false;
}
if (this == o) {
return true;
}
if (!(o instanceof Defacs rhs)) {
return false;
}
return (Objects.equals(auth, rhs.auth)) && (Objects.equals(anon, rhs.anon));
}
public String getAuth() {
return auth != null ? auth.toString() : null;
}
public void setAuth(String a) {
auth = new AcsHelper(a);
}
public String getAnon() {
return anon != null ? anon.toString() : null;
}
public void setAnon(String a) {
anon = new AcsHelper(a);
}
public boolean merge(Defacs defacs) {
int changed = 0;
if (defacs.auth != null) {
if (auth == null) {
auth = defacs.auth;
changed ++;
} else {
changed += auth.merge(defacs.auth) ? 1 : 0;
}
}
if (defacs.anon != null) {
if (anon == null) {
anon = defacs.anon;
changed ++;
} else {
changed += anon.merge(defacs.anon) ? 1 : 0;
}
}
return changed > 0;
}
}

View File

@@ -0,0 +1,13 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
/**
* Part of Meta server response
*/
public class DelValues implements Serializable {
public Integer clear;
public MsgRange[] delseq;
public DelValues() {}
}

View File

@@ -0,0 +1,283 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.util.Date;
import co.tinode.tinodesdk.Tinode;
/**
* Topic description as deserialized from the server packet.
*/
public class Description<DP, DR> implements Serializable {
public Date created;
public Date updated;
public Date touched;
public Boolean online;
public Defacs defacs;
public Acs acs;
public int seq;
// Values reported by the current user as read and received
public int read;
public int recv;
public int clear;
// Merged from Subscription.
public int subcnt;
public boolean chan;
@JsonProperty("public")
public DP pub;
@JsonProperty("private")
public DR priv;
public TrustedType trusted;
public LastSeen seen;
public Description() {
}
private boolean mergePub(DP spub) {
boolean changed;
if (Tinode.isNull(spub)) {
pub = null;
changed = true;
} else {
if (pub != null && (pub instanceof Mergeable)) {
changed = ((Mergeable)pub).merge((Mergeable)spub);
} else {
pub = spub;
changed = true;
}
}
return changed;
}
private boolean mergePriv(DR spriv) {
boolean changed;
if (Tinode.isNull(spriv)) {
priv = null;
changed = true;
} else {
if (priv != null && (priv instanceof Mergeable)) {
changed = ((Mergeable)priv).merge((Mergeable)spriv);
} else {
priv = spriv;
changed = true;
}
}
return changed;
}
/**
* Copy non-null values to this object.
*
* @param desc object to copy.
*/
public boolean merge(Description<DP,DR> desc) {
boolean changed = false;
if (created == null && desc.created != null) {
created = desc.created;
changed = true;
}
if (desc.updated != null && (updated == null || updated.before(desc.updated))) {
updated = desc.updated;
changed = true;
}
if (desc.touched != null && (touched == null || touched.before(desc.touched))) {
touched = desc.touched;
changed = true;
}
if (chan != desc.chan) {
chan = desc.chan;
changed = true;
}
if (desc.defacs != null) {
if (defacs == null) {
defacs = desc.defacs;
changed = true;
} else {
changed = defacs.merge(desc.defacs) || changed;
}
}
if (desc.acs != null) {
if (acs == null) {
acs = desc.acs;
changed = true;
} else {
changed = acs.merge(desc.acs) || changed;
}
}
if (desc.seq > seq) {
seq = desc.seq;
changed = true;
}
if (desc.read > read) {
read = desc.read;
changed = true;
}
if (desc.recv > recv) {
recv = desc.recv;
changed = true;
}
if (desc.clear > clear) {
clear = desc.clear;
changed = true;
}
if (desc.subcnt > 0) {
changed = subcnt != desc.subcnt || changed;
subcnt = desc.subcnt;
}
if (desc.pub != null) {
changed = mergePub(desc.pub) || changed;
}
if (desc.trusted != null) {
if (trusted == null) {
trusted = new TrustedType();
changed = true;
}
changed = trusted.merge(desc.trusted) || changed;
}
if (desc.priv != null) {
changed = mergePriv(desc.priv) || changed;
}
if (desc.online != null && desc.online != online) {
online = desc.online;
changed = true;
}
if (desc.seen != null) {
if (seen == null) {
seen = desc.seen;
changed = true;
} else {
changed = seen.merge(desc.seen) || changed;
}
}
return changed;
}
/**
* Merge subscription into a description
*/
public <SP,SR> boolean merge(Subscription<SP,SR> sub) {
boolean changed = false;
if (sub.updated != null && (updated == null || updated.before(sub.updated))) {
updated = sub.updated;
changed = true;
}
if (sub.touched != null && (touched == null || touched.before(sub.touched))) {
touched = sub.touched;
changed = true;
}
if (sub.acs != null) {
if (acs == null) {
acs = sub.acs;
changed = true;
} else {
changed = acs.merge(sub.acs) || changed;
}
}
if (sub.seq > seq) {
seq = sub.seq;
changed = true;
}
if (sub.read > read) {
read = sub.read;
changed = true;
}
if (sub.recv > recv) {
recv = sub.recv;
changed = true;
}
if (sub.clear > clear) {
clear = sub.clear;
changed = true;
}
if (sub.subcnt > 0) {
changed = subcnt != sub.subcnt || changed;
subcnt = sub.subcnt;
}
if (sub.pub != null) {
// This may throw a ClassCastException.
// This is intentional behavior to catch cases of wrong assignment.
//noinspection unchecked
changed = mergePub((DP) sub.pub) || changed;
}
if (sub.trusted != null) {
if (trusted == null) {
trusted = new TrustedType();
changed = true;
}
changed = trusted.merge(sub.trusted) || changed;
}
if (sub.priv != null) {
try {
//noinspection unchecked
changed = mergePriv((DR)sub.priv) || changed;
} catch (ClassCastException ignored) {}
}
if (sub.online != null && sub.online != online) {
online = sub.online;
changed = true;
}
if (sub.seen != null) {
if (seen == null) {
seen = sub.seen;
changed = true;
} else {
changed = seen.merge(sub.seen) || changed;
}
}
return changed;
}
public boolean merge(MetaSetDesc<DP,DR> desc) {
boolean changed = false;
if (desc.defacs != null) {
if (defacs == null) {
defacs = desc.defacs;
changed = true;
} else {
changed = defacs.merge(desc.defacs);
}
}
if (desc.pub != null) {
changed = mergePub(desc.pub) || changed;
}
if (desc.priv != null) {
changed = mergePriv(desc.priv) || changed;
}
return changed;
}
}

View File

@@ -0,0 +1,36 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
import java.util.Date;
/**
* Class to hold last seen date-time and User Agent.
*/
public class LastSeen implements Serializable {
public Date when;
public String ua;
public LastSeen() {
}
public LastSeen(Date when) {
this.when = when;
}
public LastSeen(Date when, String ua) {
this.when = when;
this.ua = ua;
}
public boolean merge(LastSeen seen) {
boolean changed = false;
if (seen != null) {
if (seen.when != null && (when == null || when.before(seen.when))) {
when = seen.when;
ua = seen.ua;
changed = true;
}
}
return changed;
}
}

View File

@@ -0,0 +1,10 @@
package co.tinode.tinodesdk.model;
/*
* Interface that allows merging of objects.
*/
public interface Mergeable {
// Merges this with |another|.
// Returns the total number of modified fields.
boolean merge(Mergeable another);
}

View File

@@ -0,0 +1,43 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.Arrays;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Parameter of MsgGetMeta
*/
@JsonInclude(NON_DEFAULT)
public class MetaGetData implements Serializable {
// Inclusive (closed): ID >= since.
public Integer since;
// Exclusive (open): ID < before.
public Integer before;
public Integer limit;
public MsgRange[] ranges;
public MetaGetData() {}
public MetaGetData(Integer since, Integer before, Integer limit) {
this.since = since;
this.before = before;
this.limit = limit;
}
public MetaGetData(MsgRange[] ranges, Integer limit) {
this.ranges = ranges;
this.limit = limit;
}
@NotNull
@Override
public String toString() {
return "since=" + since + ", before=" + before +
", ranges=" + Arrays.toString(ranges) + ", limit=" + limit;
}
}

View File

@@ -0,0 +1,27 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.Date;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Parameter of GetMeta.
*/
@JsonInclude(NON_DEFAULT)
public class MetaGetDesc implements Serializable {
// ims = If modified since...
public Date ims;
public MetaGetDesc() {}
@NotNull
@Override
public String toString() {
return "ims=" + ims;
}
}

View File

@@ -0,0 +1,45 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.Date;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Parameter of MsgGetMeta
*/
@JsonInclude(NON_DEFAULT)
public class MetaGetSub implements Serializable {
public String user;
public String topic;
public Date ims;
public Integer limit;
public MetaGetSub() {}
public MetaGetSub(Date ims, Integer limit) {
this.ims = ims;
this.limit = limit;
}
public void setUser(String user) {
this.user = user;
}
public void setTopic(String topic) {
this.topic = topic;
}
@NotNull
@Override
public String toString() {
return "user=[" + user + "]," +
" topic=[" + topic + "]," +
" ims=[" + ims + "]," +
" limit=[" + limit + "]";
}
}

View File

@@ -0,0 +1,40 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Topic initiation parameters
*/
@JsonInclude(NON_DEFAULT)
public class MetaSetDesc<P,R> implements Serializable {
public Defacs defacs;
@JsonProperty("public")
public P pub;
@JsonProperty("private")
public R priv;
@JsonIgnore
public String[] attachments;
public MetaSetDesc() {}
public MetaSetDesc(P pub, R priv, Defacs da) {
this.defacs = da;
this.pub = pub;
this.priv = priv;
}
public MetaSetDesc(P pub, R priv) {
this(pub, priv, null);
}
public MetaSetDesc(Defacs da) {
this(null, null, da);
}
}

View File

@@ -0,0 +1,28 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Parameter of MsgSetMeta
*/
@JsonInclude(NON_DEFAULT)
public class MetaSetSub implements Serializable {
public String user;
public String mode;
public MetaSetSub() {}
public MetaSetSub(String mode) {
this.user = null;
this.mode = mode;
}
public MetaSetSub(String user, String mode) {
this.user = user;
this.mode = mode;
}
}

View File

@@ -0,0 +1,64 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import java.util.Arrays;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Account creation packet
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientAcc<Pu,Pr> implements Serializable {
public final String id;
public final String user;
public String tmpscheme;
public String tmpsecret;
public final String scheme;
public final String secret;
// Use the new account for immediate authentication.
public final Boolean login;
public String[] tags;
public Credential[] cred;
// New account parameters
public final MetaSetDesc<Pu,Pr> desc;
public MsgClientAcc(String id, String uid, String scheme, String secret, boolean doLogin,
MetaSetDesc<Pu, Pr> desc) {
this.id = id;
this.user = uid;
this.scheme = scheme;
this.secret = secret;
this.login = doLogin;
this.desc = desc;
}
@JsonIgnore
public void addTag(String tag) {
if (tags == null) {
tags = new String[1];
} else {
tags = Arrays.copyOf(tags, tags.length + 1);
}
tags[tags.length-1] = tag;
}
@JsonIgnore
public void addCred(Credential c) {
if (cred == null) {
cred = new Credential[1];
} else {
cred = Arrays.copyOf(cred, cred.length + 1);
}
cred[cred.length-1] = c;
}
@JsonIgnore
public void setTempAuth(String scheme, String secret) {
this.tmpscheme = scheme;
this.tmpsecret = secret;
}
}

View File

@@ -0,0 +1,102 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import co.tinode.tinodesdk.Tinode;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Topic or message deletion packet
* <p>
* Id string `json:"id,omitempty"`
* Topic string `json:"topic"`
* // what to delete, either "msg" to delete messages, "topic" to delete the topic, "sub" to delete subscription
* What string `json:"what"`
* // Delete messages older than this seq ID (inclusive)
* Before int `json:"before"`
* // Request to hard-delete messages for all users, if such option is available.
* Hard bool `json:"hard,omitempty"`
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientDel implements Serializable {
private final static String STR_TOPIC = "topic";
private final static String STR_MSG = "msg";
private final static String STR_SUB = "sub";
private final static String STR_CRED = "cred";
private final static String STR_USER = "user";
public String id;
public String topic;
public String what;
public MsgRange[] delseq;
public String user;
public Credential cred;
public Boolean hard;
public MsgClientDel() {}
private MsgClientDel(String id, String topic, String what, MsgRange[] ranges, String user, Credential cred, boolean hard) {
this.id = id;
this.topic = topic;
this.what = what;
// null value will cause the field to be skipped during serialization instead of sending 0/null/[].
this.delseq = what.equals(STR_MSG) ? ranges : null;
this.user = what.equals(STR_SUB) || what.equals(STR_USER) ? user : null;
this.cred = what.equals(STR_CRED) ? cred : null;
this.hard = hard ? true : null;
}
/**
* Delete all messages in multiple ranges
*/
public MsgClientDel(String id, String topic, MsgRange[] ranges, boolean hard) {
this(id, topic, STR_MSG, ranges, null, null, hard);
}
/**
* Delete all messages in one range.
*/
public MsgClientDel(String id, String topic, int fromId, int toId, boolean hard) {
this(id, topic, new MsgRange[]{new MsgRange(fromId, toId)}, hard);
}
/**
* Delete one message.
*/
public MsgClientDel(String id, String topic, int seqId, boolean hard) {
this(id, topic, STR_MSG, new MsgRange[]{new MsgRange(seqId)}, null, null, hard);
}
/**
* Delete topic
*/
public MsgClientDel(String id, String topic) {
this(id, topic, STR_TOPIC, null, null, null, false);
}
/**
* Delete current user.
*/
public MsgClientDel(String id) {
this(id, null, STR_USER, null, null, null, false);
}
/**
* Delete subscription of the given user. The server will reject request if the <i>user</i> is null.
*/
public MsgClientDel(String id, String topic, String user) {
this(id, topic, STR_SUB, null, user, null, false);
}
/**
* Delete selected credential.
*/
public MsgClientDel(String id, Credential cred) {
this(id, Tinode.TOPIC_ME, STR_CRED, null, null, cred, false);
}
}

View File

@@ -0,0 +1,15 @@
package co.tinode.tinodesdk.model;
// MsgClientExtra is not a stand-alone message but extra data which augments the main payload.
public class MsgClientExtra {
public MsgClientExtra() {
attachments = null;
}
public MsgClientExtra(String[] attachments) {
this.attachments = attachments;
}
// Array of out-of-band attachments which have to be exempted from GC.
public final String[] attachments;
}

View File

@@ -0,0 +1,58 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Metadata query packet
* Id string `json:"id,omitempty"`
* Topic string `json:"topic"`
* What string `json:"what"`
* Desc *MsgGetOpts `json:"desc,omitempty"`
* Sub *MsgGetOpts `json:"sub,omitempty"`
* Data *MsgBrowseOpts `json:"data,omitempty"`
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientGet implements Serializable {
public String id;
public String topic;
public String what;
public MetaGetDesc desc;
public MetaGetSub sub;
public MetaGetData data;
public MsgClientGet() {}
public MsgClientGet(String id, String topic, MsgGetMeta query) {
this.id = id;
this.topic = topic;
this.what = query.what;
this.desc = query.desc;
this.sub = query.sub;
this.data = query.data;
}
public MsgClientGet(String id, String topic, MetaGetDesc desc,
MetaGetSub sub, MetaGetData data) {
this.id = id;
this.topic = topic;
this.what = "";
if (desc != null) {
this.what = "desc";
this.desc = desc;
}
if (sub != null) {
this.what += " sub";
this.sub = sub;
}
if (data != null) {
this.what += " data";
this.data = data;
}
this.what = this.what.trim();
}
}

View File

@@ -0,0 +1,30 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Create client handshake packet.
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientHi implements Serializable {
public final String id;
public final String ver;
public final String ua; // User Agent
public final String dev; // Device ID
public final String lang;
public final Boolean bkg;
public MsgClientHi(String id, String version, String userAgent, String deviceId, String lang, Boolean background) {
this.id = id;
this.ver = version;
this.ua = userAgent;
this.dev = deviceId;
this.lang = lang;
this.bkg = background;
}
}

View File

@@ -0,0 +1,26 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Topic unsubscribe packet.
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientLeave implements Serializable {
public String id;
public String topic;
public Boolean unsub;
public MsgClientLeave() {
}
public MsgClientLeave(String id, String topic, boolean unsub) {
this.id = id;
this.topic = topic;
this.unsub = unsub ? true : null;
}
}

View File

@@ -0,0 +1,45 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import java.util.Arrays;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Login packet.
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientLogin implements Serializable {
public String id;
public String scheme; // "basic" or "token"
public String secret; // i.e. <uname + ":" + password> or token
public Credential []cred;
public MsgClientLogin() {
}
public MsgClientLogin(String id, String scheme, String secret) {
this.id = id;
this.scheme = scheme;
this.secret = secret;
}
public void Login(String scheme, String secret) {
this.scheme = scheme;
this.secret = secret;
}
@JsonIgnore
public void addCred(Credential c) {
if (cred == null) {
cred = new Credential[1];
} else {
cred = Arrays.copyOf(cred, cred.length + 1);
}
cred[cred.length-1] = c;
}
}

View File

@@ -0,0 +1,35 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Typing and read/received notifications packet.
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientNote implements Serializable {
public final String topic; // topic to notify, required
public final String what; // one of "kp" (key press), "read" (read notification),
// "rcpt" (received notification), "call" (video call event),
// any other string will cause
// message to be silently dropped, required
public final Integer seq; // ID of the message being acknowledged, required for rcpt & read
public final String event; // Event (set only when what="call")
public final Object payload; // Arbitrary json payload (set only when what="call")
public MsgClientNote(String topic, String what, int seq) {
this(topic, what, seq, null, null);
}
public MsgClientNote(String topic, String what, int seq, String event, Object payload) {
this.topic = topic;
this.what = what;
this.seq = seq > 0 ? seq : null;
this.event = event;
this.payload = payload;
}
}

View File

@@ -0,0 +1,31 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import java.util.Map;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Publish to topic packet.
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientPub implements Serializable {
public String id;
public String topic;
public Boolean noecho;
public Map<String, Object> head;
public Object content;
public MsgClientPub() {
}
public MsgClientPub(String id, String topic, Boolean noecho, Object content, Map<String, Object> head) {
this.id = id;
this.topic = topic;
this.noecho = noecho ? true : null;
this.content = content;
this.head = head;
}
}

View File

@@ -0,0 +1,105 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import java.util.Map;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Metadata update packet: description, subscription, tags, credentials.
* <p>
* topic metadata, new topic &amp; new subscriptions only
* Desc *MsgSetDesc `json:"desc,omitempty"`
* <p>
* Subscription parameters
* Sub *MsgSetSub `json:"sub,omitempty"`
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientSet<Pu,Pr> implements Serializable {
// Keep track of NULL assignments to fields.
@JsonIgnore
int nulls = 0;
public String id;
public String topic;
public MetaSetDesc<Pu,Pr> desc;
public MetaSetSub sub;
public String[] tags;
public Credential cred;
public Map<String, Object> aux;
public MsgClientSet() {}
public MsgClientSet(String id, String topic, MsgSetMeta<Pu,Pr> meta) {
this(id, topic, meta.desc, meta.sub, meta.tags, meta.cred, meta.aux);
nulls = meta.nulls;
}
protected MsgClientSet(String id, String topic) {
this.id = id;
this.topic = topic;
}
protected MsgClientSet(String id, String topic, MetaSetDesc<Pu, Pr> desc,
MetaSetSub sub, String[] tags, Credential cred,
Map<String, Object> aux) {
this.id = id;
this.topic = topic;
this.desc = desc;
this.sub = sub;
this.tags = tags;
this.cred = cred;
this.aux = aux;
}
public static class Builder<Pu,Pr> {
private final MsgClientSet<Pu,Pr> msm;
public Builder(String id, String topic) {
msm = new MsgClientSet<>(id, topic);
}
public void with(MetaSetDesc<Pu,Pr> desc) {
msm.desc = desc;
if (desc == null) {
msm.nulls |= MsgSetMeta.NULL_DESC;
}
}
public void with(MetaSetSub sub) {
msm.sub = sub;
if (sub == null) {
msm.nulls |= MsgSetMeta.NULL_SUB;
}
}
public void with(String[] tags) {
msm.tags = tags;
if (tags == null || tags.length == 0) {
msm.nulls |= MsgSetMeta.NULL_TAGS;
}
}
public void with(Credential cred) {
msm.cred = cred;
if (cred == null) {
msm.nulls |= MsgSetMeta.NULL_CRED;
}
}
public void with(Map<String,Object> aux) {
msm.aux = aux;
if (aux == null || aux.isEmpty()) {
msm.nulls |= MsgSetMeta.NULL_AUX;
}
}
public MsgClientSet<Pu,Pr> build() {
return msm;
}
}
}

View File

@@ -0,0 +1,62 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import co.tinode.tinodesdk.Tinode;
/**
* Custom serializer for MsgSetMeta to serialize assigned NULL fields as Tinode.NULL_VALUE string.
*/
public class MsgClientSetSerializer extends StdSerializer<MsgClientSet<?,?>> {
public MsgClientSetSerializer() {
super(MsgClientSet.class, false);
}
@Override
public void serialize(MsgClientSet<?,?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeStartObject();
if (value.id != null && !value.id.isEmpty()) {
gen.writeStringField("id", value.id);
}
if (value.topic != null && !value.topic.isEmpty()) {
gen.writeStringField("topic", value.topic);
}
if (value.desc != null) {
gen.writeObjectField("desc", value.desc);
} else if ((value.nulls & MsgSetMeta.NULL_DESC) != 0) {
gen.writeStringField("desc", Tinode.NULL_VALUE);
}
if (value.sub != null) {
gen.writeObjectField("sub", value.sub);
} else if ((value.nulls & MsgSetMeta.NULL_SUB) != 0) {
gen.writeStringField("sub", Tinode.NULL_VALUE);
}
if (value.tags != null && value.tags.length != 0) {
gen.writeFieldName("tags");
gen.writeArray(value.tags, 0, value.tags.length);
} else if ((value.nulls & MsgSetMeta.NULL_TAGS) != 0) {
gen.writeFieldName("tags");
gen.writeArray(new String[]{Tinode.NULL_VALUE}, 0, 1);
}
if (value.cred != null) {
gen.writeObjectField("cred", value.cred);
} else if ((value.nulls & MsgSetMeta.NULL_CRED) != 0) {
gen.writeStringField("cred", Tinode.NULL_VALUE);
}
if (value.aux != null) {
gen.writeObjectField("aux", value.aux);
} else if ((value.nulls & MsgSetMeta.NULL_AUX) != 0) {
gen.writeStringField("aux", Tinode.NULL_VALUE);
}
gen.writeEndObject();
}
}

View File

@@ -0,0 +1,28 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Subscribe to topic packet.
*
*/
@JsonInclude(NON_DEFAULT)
public class MsgClientSub<Pu,Pr> implements Serializable {
public String id;
public String topic;
public MsgSetMeta<Pu,Pr> set;
public MsgGetMeta get;
public MsgClientSub() {}
public MsgClientSub(String id, String topic, MsgSetMeta<Pu,Pr> set, MsgGetMeta get) {
this.id = id;
this.topic = topic;
this.set = set;
this.get = get;
}
}

View File

@@ -0,0 +1,253 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Topic metadata request.
*/
@JsonInclude(NON_DEFAULT)
public class MsgGetMeta implements Serializable {
private static final int DESC_SET = 0x01;
private static final int SUB_SET = 0x02;
private static final int DATA_SET = 0x04;
private static final int DEL_SET = 0x08;
private static final int TAGS_SET = 0x10;
private static final int CRED_SET = 0x20;
private static final int AUX_SET = 0x40;
private static final String DESC = "desc";
private static final String SUB = "sub";
private static final String DATA = "data";
private static final String DEL = "del";
private static final String TAGS = "tags";
private static final String CRED = "cred";
private static final String AUX = "aux";
@JsonIgnore
private int mSet = 0;
public String what;
public MetaGetDesc desc;
public MetaGetSub sub;
public MetaGetData data;
public MetaGetData del;
/**
* Empty query.
*/
public MsgGetMeta() { }
/**
* Generate query to get specific data:
*
* @param desc request topic description
* @param sub request subscriptions
* @param data request data messages
*/
public MsgGetMeta(MetaGetDesc desc, MetaGetSub sub, MetaGetData data, MetaGetData del,
Boolean tags, Boolean cred, Boolean aux) {
this.desc = desc;
this.sub = sub;
this.data = data;
this.del = del;
if (tags != null && tags) {
this.mSet = TAGS_SET;
}
if (cred != null && cred) {
this.mSet |= CRED_SET;
}
if (aux != null && aux) {
this.mSet |= AUX_SET;
}
buildWhat();
}
/**
* Generate query to get subscription:
*
* @param sub request subscriptions
*/
public MsgGetMeta(MetaGetSub sub) {
this.sub = sub;
buildWhat();
}
private MsgGetMeta(String what) {
this.what = what;
}
@NotNull
@Override
public String toString() {
return "[" + what + "]" +
" desc=[" + (desc != null ? desc.toString() : "null") + "]," +
" sub=[" + (sub != null? sub.toString() : "null") + "]," +
" data=[" + (data != null ? data.toString() : "null") + "]," +
" del=[" + (del != null ? del.toString() : "null") + "]" +
" tags=[" + ((mSet & TAGS_SET) != 0 ? "set" : "null") + "]" +
" cred=[" + ((mSet & CRED_SET) != 0 ? "set" : "null") + "]" +
" aux=[" + ((mSet & AUX_SET) != 0 ? "set" : "null") + "]";
}
/**
* Request topic description
*
* @param ims timestamp to receive public if it's newer than ims; could be null
*/
// Do not add @JsonIgnore here.
public void setDesc(Date ims) {
if (ims != null) {
desc = new MetaGetDesc();
desc.ims = ims;
}
mSet |= DESC_SET;
buildWhat();
}
// Do not add @JsonIgnore here.
public void setSub(Date ims, Integer limit) {
if (ims != null || limit != null) {
sub = new MetaGetSub(ims, limit);
}
mSet |= SUB_SET;
buildWhat();
}
@JsonIgnore
public void setSubUser(String user, Date ims, Integer limit) {
if (ims != null || limit != null || user != null) {
sub = new MetaGetSub(ims, limit);
sub.setUser(user);
}
mSet |= SUB_SET;
buildWhat();
}
@JsonIgnore
public void setSubTopic(String topic, Date ims, Integer limit) {
if (ims != null || limit != null || topic != null) {
sub = new MetaGetSub(ims, limit);
sub.setTopic(topic);
}
mSet |= SUB_SET;
buildWhat();
}
// Do not add @JsonIgnore here.
public void setData(Integer since, Integer before, Integer limit) {
if (since != null || before != null || limit != null) {
data = new MetaGetData(since, before, limit);
}
mSet |= DATA_SET;
buildWhat();
}
public void setData(MsgRange[] ranges, Integer limit) {
if (ranges != null || limit != null) {
data = new MetaGetData(ranges, limit);
}
mSet |= DATA_SET;
buildWhat();
}
// Do not add @JsonIgnore here.
public void setDel(Integer since, Integer limit) {
if (since != null || limit != null) {
del = new MetaGetData(since, null, limit);
}
mSet |= DEL_SET;
buildWhat();
}
// Do not add @JsonIgnore here.
public void setTags() {
mSet |= TAGS_SET;
buildWhat();
}
// Do not add @JsonIgnore here.
public void setCred() {
mSet |= CRED_SET;
buildWhat();
}
// Do not add @JsonIgnore here.
public void setAux() {
mSet |= AUX_SET;
buildWhat();
}
@JsonIgnore
private void buildWhat() {
List<String> parts = new LinkedList<>();
StringBuilder sb = new StringBuilder();
if (desc != null || (mSet & DESC_SET) != 0) {
parts.add(DESC);
}
if (sub != null || (mSet & SUB_SET) != 0) {
parts.add(SUB);
}
if (data != null || (mSet & DATA_SET) != 0) {
parts.add(DATA);
}
if (del != null || (mSet & DEL_SET) != 0) {
parts.add(DEL);
}
if ((mSet & TAGS_SET) != 0) {
parts.add(TAGS);
}
if ((mSet & CRED_SET) != 0) {
parts.add(CRED);
}
if ((mSet & AUX_SET) != 0) {
parts.add(AUX);
}
if (!parts.isEmpty()) {
sb.append(parts.get(0));
for (int i=1; i < parts.size(); i++) {
sb.append(" ").append(parts.get(i));
}
}
what = sb.toString();
}
@JsonIgnore
public boolean isEmpty() {
return mSet == 0;
}
public static MsgGetMeta desc() {
return new MsgGetMeta(DESC);
}
public static MsgGetMeta data() {
return new MsgGetMeta(DATA);
}
public static MsgGetMeta sub() {
return new MsgGetMeta(SUB);
}
public static MsgGetMeta del() {
return new MsgGetMeta(DEL);
}
public static MsgGetMeta tags() {
return new MsgGetMeta(TAGS);
}
public static MsgGetMeta cred() {
return new MsgGetMeta(CRED);
}
}

View File

@@ -0,0 +1,285 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
/**
* Range is either an individual ID (hi=0 || hi==null) or a range of deleted IDs, low end inclusive (closed),
* high-end exclusive (open): [low .. hi), e.g. 1..5 → 1, 2, 3, 4
*/
@JsonInclude(NON_DEFAULT)
@SuppressWarnings("WeakerAccess")
public class MsgRange implements Comparable<MsgRange>, Serializable {
// The low value is required, thus it's a primitive type.
public final int low;
// The high value is optional.
public Integer hi;
public MsgRange() {
low = 0;
}
public MsgRange(int id) {
low = id;
}
public MsgRange(int low, int hi) {
this.low = low;
this.hi = hi;
}
public MsgRange(MsgRange that) {
this.low = that.low;
this.hi = that.hi;
}
@Override
public int compareTo(MsgRange other) {
int r = low - other.low;
if (r == 0) {
r = nullableCompare(other.hi, hi);
}
return r;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
MsgRange other = (MsgRange) obj;
return low == other.low && nullableCompare(hi, other.hi) == 0;
}
@SuppressWarnings("NullableProblems")
@Override
public String toString() {
return "{low: " + low + (hi != null ? (", hi: " + hi) : "") + "}";
}
@JsonIgnore
public int getLower() {
return low;
}
@JsonIgnore
public int getUpper() {
return (hi != null && hi != 0) ? hi : low + 1;
}
protected boolean tryExtending(int h) {
boolean done = false;
if (h == low) {
done = true;
} else if (hi != null && hi != 0) {
if (h == hi) {
hi ++;
done = true;
}
} else if (h == low + 1) {
hi = h + 1;
done = true;
}
return done;
}
// If <b>hi</b> is meaningless or invalid, remove it.
protected void normalize() {
if (hi != null && hi <= low + 1) {
hi = null;
}
}
/**
* Convert List of IDs to multiple ranges.
*/
public static MsgRange[] toRanges(@Nullable final List<Integer> list) {
if (list == null || list.isEmpty()) {
return null;
}
// Make sure the IDs are sorted in ascending order.
Collections.sort(list);
ArrayList<MsgRange> ranges = new ArrayList<>();
MsgRange curr = new MsgRange(list.get(0));
ranges.add(curr);
for (int i = 1; i < list.size(); i++) {
if (!curr.tryExtending(list.get(i))) {
curr.normalize();
// Start a new range
curr = new MsgRange(list.get(i));
ranges.add(curr);
}
}
// No need to sort the ranges. They are already sorted.
return ranges.toArray(new MsgRange[]{});
}
/**
* Convert array of IDs to multiple ranges.
*/
public static @Nullable MsgRange[] toRanges(final int[] list) {
if (list == null || list.length == 0) {
return null;
}
// Make sure the IDs are sorted in ascending order.
Arrays.sort(list);
ArrayList<MsgRange> ranges = new ArrayList<>();
MsgRange curr = new MsgRange(list[0]);
ranges.add(curr);
for (int i = 1; i < list.length; i++) {
if (!curr.tryExtending(list[i])) {
curr.normalize();
// Start a new range
curr = new MsgRange(list[i]);
ranges.add(curr);
}
}
// No need to sort the ranges. They are already sorted.
return ranges.toArray(new MsgRange[0]);
}
/**
* Collapse multiple possibly overlapping ranges into as few ranges non-overlapping
* ranges as possible: [1..6],[2..4],[5..7] -> [1..7].
* The input array of ranges must be sorted.
*
* @param ranges ranges to collapse
* @return non-overlapping ranges.
*/
public static @NotNull MsgRange[] collapse(@NotNull MsgRange[] ranges) {
if (ranges.length < 2) {
return ranges;
}
int prev = 0;
for (int i = 1; i < ranges.length; i++) {
if (ranges[prev].low == ranges[i].low) {
// Same starting point.
// Earlier range is guaranteed to be wider or equal to the later range,
// collapse two ranges into one (by doing nothing)
continue;
}
// Check for full or partial overlap
int prev_hi = ranges[prev].getUpper();
if (prev_hi >= ranges[i].low) {
// Partial overlap: previous hi is above or equal to current low.
int curr_hi = ranges[i].getUpper();
if (curr_hi > prev_hi) {
// Current range extends further than previous, extend previous.
ranges[prev].hi = curr_hi;
}
// Otherwise the next range is fully within the previous range, consume it by doing nothing.
continue;
}
// No overlap. Just copy the values.
prev ++;
ranges[prev] = ranges[i];
}
// Clip array.
if (prev + 1 < ranges.length) {
ranges = Arrays.copyOfRange(ranges, 0, prev + 1);
}
return ranges;
}
/**
* Get maximum enclosing range. The input array must be sorted.
*/
public static @Nullable MsgRange enclosing(final @NotNull MsgRange[] ranges) {
if (ranges == null || ranges.length == 0) {
return null;
}
MsgRange first = new MsgRange(ranges[0]);
if (ranges.length > 1) {
MsgRange last = ranges[ranges.length - 1];
first.hi = last.getUpper();
} else if (first.hi == null) {
first.hi = first.low + 1;
}
return first;
}
/**
* Find gaps in the given array of non-overlapping ranges. The input must be sorted and overlaps removed.
*/
public static @NotNull MsgRange[] gaps(@NotNull MsgRange[] ranges) {
if (ranges.length < 2) {
return new MsgRange[0];
}
List<MsgRange> gaps = new LinkedList<>();
for (int i = 1; i < ranges.length; i++) {
if (ranges[i-1].getUpper() < ranges[i].getLower()) {
// Gap found
gaps.add(new MsgRange(ranges[i-1].getUpper(), ranges[i].getLower()));
}
}
return gaps.toArray(new MsgRange[0]);
}
// Comparable which does not crash on null values. Nulls are treated as 0.
private static int nullableCompare(Integer x, Integer y) {
return (x == null ? 0 : x) - (y == null ? 0 : y);
}
/**
* Cut 'clip' range out of the 'src' range.
*
* @param src source range to subtract from.
* @param clip range to subtract.
* @return array with 0, 1 or 2 elements.
*/
public static @NotNull MsgRange[] clip(@NotNull MsgRange src, @NotNull MsgRange clip) {
if (clip.getUpper() < src.getLower() || clip.getLower() >= src.getUpper()) {
// Clip is completely outside of src, no intersection.
return new MsgRange[]{src};
}
if (clip.low <= src.low) {
if (clip.getUpper() >= src.getUpper()) {
// The source range is completely inside the clipping range.
return new MsgRange[0];
}
// Partial clipping at the top.
return new MsgRange[]{new MsgRange(src.getLower(), clip.getUpper())};
}
// Range on the lower end.
MsgRange lower = new MsgRange(src.getLower(), clip.getLower());
if (clip.getUpper() < src.getUpper()) {
return new MsgRange[]{lower, new MsgRange(clip.getUpper(), src.getUpper())};
}
return new MsgRange[]{lower};
}
}

View File

@@ -0,0 +1,54 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.io.Serializable;
import java.util.Date;
import java.util.Iterator;
import java.util.Map;
/**
* Control packet
*/
public class MsgServerCtrl implements Serializable {
public String id;
public String topic;
public int code;
public String text;
public Date ts;
public Map<String, Object> params;
public MsgServerCtrl() {
}
@JsonIgnore
private Object getParam(String key, Object def) {
if (params == null) {
return def;
}
Object result = params.get(key);
return result != null ? result : def;
}
@JsonIgnore
public Integer getIntParam(String key, Integer def) {
return (Integer) getParam(key, def);
}
@JsonIgnore
public String getStringParam(String key, String def) {
return (String) getParam(key, def);
}
@JsonIgnore
public Boolean getBoolParam(String key, Boolean def) {
return (Boolean) getParam(key, def);
}
@JsonIgnore
@SuppressWarnings("unchecked")
public Iterator<String> getStringIteratorParam(String key) {
Iterable<String> it = params != null ? (Iterable<String>) params.get(key) : null;
return it != null && it.iterator().hasNext() ? it.iterator() : null;
}
}

View File

@@ -0,0 +1,78 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.io.Serializable;
import java.util.Date;
import java.util.Map;
/**
* Content packet
*/
public class MsgServerData implements Serializable {
public enum WebRTC {ACCEPTED, BUSY, DECLINED, DISCONNECTED, FINISHED, MISSED, STARTED, UNKNOWN}
public String id;
public String topic;
public Map<String, Object> head;
public String from;
public Date ts;
public int seq;
public Drafty content;
public MsgServerData() {
}
@JsonIgnore
public Object getHeader(String key) {
return head == null ? null : head.get(key);
}
@JsonIgnore
public int getIntHeader(String key, int def) {
Object val = getHeader(key);
if (val instanceof Integer) {
return (int) val;
}
return def;
}
@JsonIgnore
public String getStringHeader(String key) {
Object val = getHeader(key);
if (val instanceof String) {
return (String) val;
}
return null;
}
@JsonIgnore
public boolean getBooleanHeader(String key) {
Object val = getHeader(key);
if (val instanceof Boolean) {
return (Boolean) val;
}
return false;
}
public static WebRTC parseWebRTC(String what) {
if (what == null) {
return WebRTC.UNKNOWN;
} else if (what.equals("accepted")) {
return WebRTC.ACCEPTED;
} else if (what.equals("busy")) {
return WebRTC.BUSY;
} else if (what.equals("declined")) {
return WebRTC.DECLINED;
} else if (what.equals("disconnected")) {
return WebRTC.DISCONNECTED;
} else if (what.equals("finished")) {
return WebRTC.FINISHED;
} else if (what.equals("missed")) {
return WebRTC.MISSED;
} else if (what.equals("started")) {
return WebRTC.STARTED;
}
return WebRTC.UNKNOWN;
}
}

View File

@@ -0,0 +1,64 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
/**
* Info packet
*/
public class MsgServerInfo implements Serializable {
public enum What {CALA, CALL, KP, KPA, KPV, RECV, READ, UNKNOWN}
public enum Event {ACCEPT, ANSWER, HANG_UP, ICE_CANDIDATE, OFFER, RINGING, UNKNOWN}
public String topic;
public String src;
public String from;
public String what;
public Integer seq;
// "event" and "payload" are video call event and its associated JSON payload.
// Set only when what="call".
public String event;
public Object payload;
public MsgServerInfo() {
}
public static What parseWhat(String what) {
if (what == null) {
return What.UNKNOWN;
} else if (what.equals("kp")) {
return What.KP;
} else if (what.equals("kpa")) {
return What.KPA;
} else if (what.equals("kpv")) {
return What.KPV;
} else if (what.equals("recv")) {
return What.RECV;
} else if (what.equals("read")) {
return What.READ;
} else if (what.equals("call")) {
return What.CALL;
} else if (what.equals("cala")) {
return What.CALA;
}
return What.UNKNOWN;
}
public static Event parseEvent(String event) {
if (event == null) {
return Event.UNKNOWN;
} else if (event.equals("accept")) {
return Event.ACCEPT;
} else if (event.equals("answer")) {
return Event.ANSWER;
} else if (event.equals("hang-up")) {
return Event.HANG_UP;
} else if (event.equals("ice-candidate")) {
return Event.ICE_CANDIDATE;
} else if (event.equals("offer")) {
return Event.OFFER;
} else if (event.equals("ringing")) {
return Event.RINGING;
}
return Event.UNKNOWN;
}
}

View File

@@ -0,0 +1,23 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
import java.util.Date;
import java.util.Map;
/**
* Metadata packet
*/
public class MsgServerMeta<DP, DR, SP, SR> implements Serializable {
public String id;
public String topic;
public Date ts;
public Description<DP,DR> desc;
public Subscription<SP,SR>[] sub;
public DelValues del;
public String[] tags;
public Credential[] cred;
public Map<String, Object> aux;
public MsgServerMeta() {
}
}

View File

@@ -0,0 +1,57 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
/**
* Presence notification.
*/
public class MsgServerPres implements Serializable {
public enum What {ON, OFF, UPD, GONE, TERM, ACS, MSG, UA, RECV, READ, DEL, TAGS, AUX, UNKNOWN}
public String topic;
public String src;
public String what;
public Integer seq;
public Integer clear;
public MsgRange[] delseq;
public String ua;
public String act;
public String tgt;
public AccessChange dacs;
public MsgServerPres() {
}
public static What parseWhat(String what) {
if (what == null) {
return What.UNKNOWN;
} else if (what.equals("on")) {
return What.ON;
} else if (what.equals("off")) {
return What.OFF;
} else if (what.equals("upd")) {
return What.UPD;
} else if (what.equals("acs")) {
return What.ACS;
} else if (what.equals("gone")) {
return What.GONE;
} else if (what.equals("term")) {
return What.TERM;
} else if (what.equals("msg")) {
return What.MSG;
} else if (what.equals("ua")) {
return What.UA;
} else if (what.equals("recv")) {
return What.RECV;
} else if (what.equals("read")) {
return What.READ;
} else if (what.equals("del")) {
return What.DEL;
} else if (what.equals("tags")) {
return What.TAGS;
} else if (what.equals("aux")) {
return What.AUX;
}
return What.UNKNOWN;
}
}

View File

@@ -0,0 +1,114 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.io.Serializable;
import java.util.Map;
/**
* Payload for setting meta params, a combination of MetaSetDesc, MetaSetSub, tags, credential.
* <p>
* Must use custom serializer to handle assigned NULL values, which should be converted to Tinode.NULL_VALUE.
*/
public class MsgSetMeta<Pu,Pr> implements Serializable {
static final int NULL_DESC = 0x1;
static final int NULL_SUB = 0x2;
static final int NULL_TAGS = 0x4;
static final int NULL_CRED = 0x8;
static final int NULL_AUX = 0x10;
// Keep track of NULL assignments to fields.
@JsonIgnore
int nulls = 0;
public MetaSetDesc<Pu,Pr> desc = null;
public MetaSetSub sub = null;
public String[] tags = null;
public Credential cred = null;
public Map<String,Object> aux = null;
public MsgSetMeta() {}
public boolean isDescSet() {
return desc != null || (nulls & NULL_DESC) != 0;
}
public boolean isSubSet() {
return sub != null || (nulls & NULL_SUB) != 0;
}
public boolean isTagsSet() {
return tags != null || (nulls & NULL_TAGS) != 0;
}
public boolean isAuxSet() {
return aux != null || (nulls & NULL_AUX) != 0;
}
public boolean isCredSet() {
return cred != null || (nulls & NULL_CRED) != 0;
}
public boolean isEmpty() {
return desc == null &&
sub == null &&
tags == null &&
cred == null &&
aux == null &&
(nulls & (NULL_DESC | NULL_SUB | NULL_TAGS | NULL_CRED | NULL_AUX)) == 0;
}
public static class Builder<Pu,Pr> {
private final MsgSetMeta<Pu,Pr> msm;
public Builder() {
msm = new MsgSetMeta<>();
}
public Builder<Pu,Pr> with(MetaSetDesc<Pu,Pr> desc) {
msm.desc = desc;
if (desc == null) {
msm.nulls |= NULL_DESC;
}
return this;
}
public Builder<Pu,Pr> with(MetaSetSub sub) {
msm.sub = sub;
if (sub == null) {
msm.nulls |= NULL_SUB;
}
return this;
}
public Builder<Pu,Pr> with(String[] tags) {
msm.tags = tags;
if (tags == null || tags.length == 0) {
msm.nulls |= NULL_TAGS;
}
return this;
}
public Builder<Pu,Pr> with(Credential cred) {
msm.cred = cred;
if (cred == null) {
msm.nulls |= NULL_CRED;
}
return this;
}
public Builder<Pu,Pr> with(Map<String,Object> aux) {
msm.aux = aux;
if (aux == null || aux.isEmpty()) {
msm.nulls |= NULL_AUX;
}
return this;
}
public MsgSetMeta<Pu,Pr> build() {
return msm;
}
public boolean isEmpty() {
return msm.isEmpty();
}
}
}

View File

@@ -0,0 +1,56 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import co.tinode.tinodesdk.Tinode;
/**
* Custom serializer for MsgSetMeta to serialize assigned NULL fields as Tinode.NULL_VALUE string.
*/
public class MsgSetMetaSerializer extends StdSerializer<MsgSetMeta<?,?>> {
public MsgSetMetaSerializer() {
super(MsgSetMeta.class, false);
}
@Override
public void serialize(MsgSetMeta<?,?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeStartObject();
if (value.desc != null) {
gen.writeObjectField("desc", value.desc);
} else if ((value.nulls & MsgSetMeta.NULL_DESC) != 0) {
gen.writeStringField("desc", Tinode.NULL_VALUE);
}
if (value.sub != null) {
gen.writeObjectField("sub", value.sub);
} else if ((value.nulls & MsgSetMeta.NULL_SUB) != 0) {
gen.writeStringField("sub", Tinode.NULL_VALUE);
}
if (value.tags != null && value.tags.length != 0) {
gen.writeFieldName("tags");
gen.writeArray(value.tags, 0, value.tags.length);
} else if ((value.nulls & MsgSetMeta.NULL_TAGS) != 0) {
gen.writeFieldName("tags");
gen.writeArray(new String[]{Tinode.NULL_VALUE}, 0, 1);
}
if (value.cred != null) {
gen.writeObjectField("cred", value.cred);
} else if ((value.nulls & MsgSetMeta.NULL_CRED) != 0) {
gen.writeStringField("cred", Tinode.NULL_VALUE);
}
if (value.aux != null && !value.aux.isEmpty()) {
gen.writeObjectField("aux", value.aux);
} else if ((value.nulls & MsgSetMeta.NULL_AUX) != 0) {
gen.writeStringField("aux", Tinode.NULL_VALUE);
}
gen.writeEndObject();
}
}

View File

@@ -0,0 +1,11 @@
package co.tinode.tinodesdk.model;
public class Pair<F, S> {
public final F first;
public S second;
public Pair(F f, S s) {
first = f;
second = s;
}
}

View File

@@ -0,0 +1,120 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import co.tinode.tinodesdk.Tinode;
/**
* Common type of the `private` field of {meta}: holds structured
* data, such as comment and archival status.
*/
public class PrivateType extends HashMap<String,Object> implements Mergeable, Serializable {
public PrivateType() {
super();
}
@JsonIgnore
private Object getValue(String name) {
Object value = get(name);
if (Tinode.isNull(value)) {
value = null;
}
return value;
}
@JsonIgnore
public String getComment() {
try {
return (String) getValue("comment");
} catch (ClassCastException ignored) {}
return null;
}
@JsonIgnore
public void setComment(String comment) {
put("comment", comment != null && !comment.isEmpty() ? comment : Tinode.NULL_VALUE);
}
public Boolean isArchived() {
try {
return (Boolean) getValue("arch");
} catch (ClassCastException ignored) {}
return Boolean.FALSE;
}
@JsonIgnore
public void setArchived(boolean arch) {
put("arch", arch ? true : Tinode.NULL_VALUE);
}
@JsonIgnore
public int getPinnedRank(@NotNull String topicName) {
try {
List<String> tpins = getPinnedTopics();
if (tpins == null || tpins.isEmpty()) {
return 0;
}
int idx = tpins.indexOf(topicName);
if (idx >= 0) {
return tpins.size() - idx;
}
} catch (ClassCastException ignored) {}
return 0;
}
/**
* Get the list of pinned topics.
*
* @return List of pinned topic names including empty list,
* or null if pinned topics are not defined.
*/
@JsonIgnore
public List<String> getPinnedTopics() {
Object value = get("tpin");
if (value == null) {
// Not set, return null.
return null;
}
if (Tinode.isNull(value)) {
// Explicitly set to empty, return empty list.
return List.of();
}
List<String> tpins = null;
try {
tpins = ((List<?>) value).stream()
.map(obj -> obj instanceof String ? (String) obj : null)
.filter(Objects::nonNull)
.collect(Collectors.toList());
} catch (ClassCastException ignored) {
// The list is malformed, return null.
}
return tpins;
}
@JsonIgnore
public void setPinnedTopics(@NotNull List<String> tpins) {
if (tpins.isEmpty()) {
put("tpin", Tinode.NULL_VALUE);
return;
}
put("tpin", tpins);
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof PrivateType apt)) {
return false;
}
this.putAll(apt);
return apt.size() > 0;
}
}

View File

@@ -0,0 +1,81 @@
package co.tinode.tinodesdk.model;
import java.io.Serializable;
/**
* Combined server message
*/
public class ServerMessage<DP, DR, SP, SR> implements Serializable {
public static final int STATUS_CONTINUE = 100; // RFC 7231, 6.2.1
public static final int STATUS_SWITCHING_PROTOCOLS = 101; // RFC 7231, 6.2.2
public static final int STATUS_PROCESSING = 102; // RFC 2518, 10.1
public static final int STATUS_OK = 200; // RFC 7231, 6.3.1
public static final int STATUS_CREATED = 201; // RFC 7231, 6.3.2
public static final int STATUS_ACCEPTED = 202; // RFC 7231, 6.3.3
public static final int STATUS_NON_AUTHORITATIVE_INFO = 203; // RFC 7231, 6.3.4
public static final int STATUS_NO_CONTENT = 204; // RFC 7231, 6.3.5
public static final int STATUS_RESET_CONTENT = 205; // RFC 7231, 6.3.6
public static final int STATUS_PARTIAL_CONTENT = 206; // RFC 7233, 4.1
public static final int STATUS_MULTI_STATUS = 207; // RFC 4918, 11.1
public static final int STATUS_ALREADY_REPORTED = 208; // RFC 5842, 7.1
public static final int STATUS_MULTIPLE_CHOICES = 300; // RFC 7231, 6.4.1
public static final int STATUS_MOVED_PERMANENTLY = 301; // RFC 7231, 6.4.2
public static final int STATUS_FOUND = 302; // RFC 7231, 6.4.3
public static final int STATUS_SEE_OTHER = 303; // RFC 7231, 6.4.4
public static final int STATUS_NOT_MODIFIED = 304; // RFC 7232, 4.1
public static final int STATUS_USE_PROXY = 305; // RFC 7231, 6.4.5
public static final int STATUS_BAD_REQUEST = 400; // RFC 7231, 6.5.1
public static final int STATUS_UNAUTHORIZED = 401; // RFC 7235, 3.1
public static final int STATUS_FORBIDDEN = 403; // RFC 7231, 6.5.3
public static final int STATUS_NOT_FOUND = 404; // RFC 7231, 6.5.4
public static final int STATUS_METHOD_NOT_ALLOWED = 405; // RFC 7231, 6.5.5
public static final int STATUS_NOT_ACCEPTABLE = 406; // RFC 7231, 6.5.6
public static final int STATUS_REQUEST_TIMEOUT = 408; // RFC 7231, 6.5.7
public static final int STATUS_CONFLICT = 409; // RFC 7231, 6.5.8
public static final int STATUS_GONE = 410; // RFC 7231, 6.5.9
public static final int STATUS_INTERNAL_SERVER_ERROR = 500; // RFC 7231, 6.6.1
public static final int STATUS_NOT_IMPLEMENTED = 501; // RFC 7231, 6.6.2
public static final int STATUS_BAD_GATEWAY = 502; // RFC 7231, 6.6.3
public static final int STATUS_SERVICE_UNAVAILABLE = 503; // RFC 7231, 6.6.4
public static final int STATUS_GATEWAY_TIMEOUT = 504; // RFC 7231, 6.6.5
public static final int STATUS_HTTP_VERSION_NOT_SUPPORTED = 505; // RFC 7231, 6.6.6
public MsgServerData data;
public MsgServerMeta<DP,DR,SP,SR> meta;
public MsgServerCtrl ctrl;
public MsgServerPres pres;
public MsgServerInfo info;
public ServerMessage() {}
public ServerMessage(MsgServerData data) {
this.data = data;
}
public ServerMessage(MsgServerMeta<DP,DR,SP,SR> meta) {
this.meta = meta;
}
public ServerMessage(MsgServerCtrl ctrl) {
this.ctrl = ctrl;
}
public ServerMessage(MsgServerPres pres) {
this.pres = pres;
}
public ServerMessage(MsgServerInfo info) {
this.info = info;
}
public boolean isValid() {
int count = 0;
if (data != null) count ++;
if (meta != null) count ++;
if (ctrl != null) count ++;
if (pres != null) count ++;
if (info != null) count ++;
return count == 1;
}
}

View File

@@ -0,0 +1,204 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.util.Date;
import co.tinode.tinodesdk.LocalData;
/**
* Subscription to topic.
*/
public class Subscription<SP,SR> implements LocalData, Serializable {
public String user;
public Date updated;
public Date deleted;
public Date touched;
public Acs acs;
public int read;
public int recv;
@JsonProperty("private")
public SR priv;
public Boolean online;
public String topic;
public int seq;
public int clear;
public int subcnt;
@JsonProperty("public")
public SP pub;
public TrustedType trusted;
public LastSeen seen;
// Local values
@JsonIgnore
private Payload mLocal;
public Subscription() {
}
public Subscription(Subscription<SP,SR> sub) {
this.merge(sub);
mLocal = null;
}
@JsonIgnore
public String getUnique() {
if (topic == null) {
return user;
}
if (user == null) {
return topic;
}
return topic + ":" + user;
}
/**
* Merge two subscriptions.
*/
public boolean merge(Subscription<SP,SR> sub) {
boolean changed = false;
if (user == null && sub.user != null && !sub.user.isEmpty()) {
user = sub.user;
changed = true;
}
if ((sub.updated != null) && (updated == null || updated.before(sub.updated))) {
updated = sub.updated;
if (sub.pub != null) {
pub = sub.pub;
}
if (sub.trusted != null) {
if (trusted == null) {
trusted = new TrustedType();
}
trusted.merge(sub.trusted);
}
changed = true;
} else {
if (pub == null && sub.pub != null) {
pub = sub.pub;
changed = true;
}
if (trusted == null && sub.trusted != null) {
trusted = sub.trusted;
changed = true;
}
}
if ((sub.touched != null) && (touched == null || touched.before(sub.touched))) {
touched = sub.touched;
}
if (sub.deleted != null) {
deleted = sub.deleted;
}
if (sub.acs != null) {
if (acs == null) {
acs = new Acs(sub.acs);
changed = true;
} else {
changed = acs.merge(sub.acs) || changed;
}
}
if (sub.read > read) {
read = sub.read;
changed = true;
}
if (sub.recv > recv) {
recv = sub.recv;
changed = true;
}
if (sub.clear > clear) {
clear = sub.clear;
changed = true;
}
if (sub.subcnt > 0) {
subcnt = sub.subcnt;
changed = true;
}
if (sub.priv != null) {
priv = sub.priv;
}
if (sub.online != null) {
online = sub.online;
}
if ((topic == null || topic.isEmpty()) && sub.topic != null && !sub.topic.isEmpty()) {
topic = sub.topic;
changed = true;
}
if (sub.seq > seq) {
seq = sub.seq;
changed = true;
}
if (sub.seen != null) {
if (seen == null) {
seen = sub.seen;
changed = true;
} else {
changed = seen.merge(sub.seen) || changed;
}
}
return changed;
}
/**
* Merge changes from {meta set} packet with the subscription.
*/
public boolean merge(MetaSetSub sub) {
boolean changed = false;
if (sub.mode != null && acs == null) {
acs = new Acs();
}
if (sub.user != null && !sub.user.isEmpty()) {
if (user == null) {
user = sub.user;
changed = true;
}
if (sub.mode != null) {
acs.setGiven(sub.mode);
changed = true;
}
} else {
if (sub.mode != null) {
acs.setWant(sub.mode);
changed = true;
}
}
return changed;
}
public void updateAccessMode(AccessChange ac) {
if (acs == null) {
acs = new Acs();
}
acs.update(ac);
}
@Override
@JsonIgnore
public void setLocal(Payload value) {
mLocal = value;
}
@Override
@JsonIgnore
public Payload getLocal() {
return mLocal;
}
}

View File

@@ -0,0 +1,445 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jetbrains.annotations.NotNull;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.util.Arrays;
import co.tinode.tinodesdk.Tinode;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
@JsonInclude(NON_DEFAULT)
public class TheCard implements Serializable, Mergeable {
public final static String TYPE_HOME = "HOME";
public final static String TYPE_WORK = "WORK";
public final static String TYPE_MOBILE = "MOBILE";
public final static String TYPE_PERSONAL = "PERSONAL";
public final static String TYPE_BUSINESS = "BUSINESS";
public final static String TYPE_OTHER = "OTHER";
// Full name
public String fn;
public Name n;
public Organization org;
// List of phone numbers associated with the contact.
public Contact[] tel;
// List of contact's email addresses.
public Contact[] email;
// All other communication options.
public Contact[] comm;
// Avatar photo. Pure java does not have a useful bitmap class, so keeping it as bits here.
public Photo photo;
// Birthday.
public Birthday bday;
// Free-form description.
public String note;
public TheCard() {
}
public TheCard(String fullName, byte[] avatarBits, String avatarImageType) {
this.fn = fullName;
if (avatarBits != null) {
this.photo = new Photo(avatarBits, avatarImageType);
}
}
public TheCard(String fullName, String avatarRef, String avatarImageType) {
this.fn = fullName;
if (avatarRef != null) {
this.photo = new Photo(avatarRef, avatarImageType);
}
}
private static String typeToString(ContactType tp) {
if (tp == null) {
return null;
}
return switch (tp) {
case HOME -> TYPE_HOME;
case WORK -> TYPE_WORK;
case MOBILE -> TYPE_MOBILE;
case PERSONAL -> TYPE_PERSONAL;
case BUSINESS -> TYPE_BUSINESS;
default -> TYPE_OTHER;
};
}
private static ContactType stringToType(String str) {
if (str == null) {
return null;
}
return switch (str) {
case TYPE_HOME -> ContactType.HOME;
case TYPE_WORK -> ContactType.WORK;
case TYPE_MOBILE -> ContactType.MOBILE;
case TYPE_PERSONAL -> ContactType.PERSONAL;
case TYPE_BUSINESS -> ContactType.BUSINESS;
default -> ContactType.OTHER;
};
}
private static boolean merge(@NotNull Field[] fields, @NotNull Mergeable dst, Mergeable src) {
if (src == null) {
return false;
}
boolean updated = false;
try {
for (Field f : fields) {
Object sf = f.get(src);
Object df = f.get(dst);
// TODO: handle Collection / Array types.
// Source is provided.
if (df == null || sf == null) {
// Either source or destination is null, replace.
f.set(dst, sf);
updated = sf == df || updated;
} else if (df instanceof Mergeable) {
// Complex mergeable types, use merge().
updated = ((Mergeable) df).merge((Mergeable) sf) || updated;
} else if (!df.equals(sf)) {
if (sf instanceof String) {
// String, check for Tinode NULL.
f.set(dst, !Tinode.isNull(sf) ? sf : null);
} else {
// All other non-mergeable types: replace.
f.set(dst, sf);
}
updated = true;
}
}
} catch (IllegalAccessException ignored) {
}
return updated;
}
@JsonIgnore
public byte[] getPhotoBits() {
return photo == null ? null : photo.data;
}
@JsonIgnore
public boolean isPhotoRef() {
return photo != null && photo.ref != null;
}
@JsonIgnore
public String getPhotoRef() {
return photo != null ? photo.ref : null;
}
@JsonIgnore
public String[] getPhotoRefs() {
if (isPhotoRef()) {
return new String[] { photo.ref };
}
return null;
}
@JsonIgnore
public String getPhotoMimeType() {
return photo == null ? null : ("image/" + photo.type);
}
public void addPhone(String phone, String type) {
tel = Contact.append(tel, new Contact(type, phone));
}
public void addEmail(String addr, String type) {
email = Contact.append(email, new Contact(type, addr));
}
@JsonIgnore
public String getPhoneByType(String type) {
String phone = null;
if (tel != null) {
for (Contact tt : tel) {
if (tt.type != null && tt.type.equals(type)) {
phone = tt.uri;
break;
}
}
}
return phone;
}
@JsonIgnore
public String getPhoneByType(ContactType type) {
return getPhoneByType(typeToString(type));
}
public enum ContactType {HOME, WORK, MOBILE, PERSONAL, BUSINESS, OTHER}
public static <T extends TheCard> T copy(T dst, TheCard src) {
dst.fn = src.fn;
dst.n = src.n != null ? src.n.copy() : null;
dst.org = src.org != null ? src.org.copy() : null;
dst.tel = Contact.copyArray(src.tel);
dst.email = Contact.copyArray(src.email);
dst.comm = Contact.copyArray(src.comm);
// Shallow copy of the photo
dst.photo = src.photo != null ? src.photo.copy() : null;
return dst;
}
public TheCard copy() {
return copy(new TheCard(), this);
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof TheCard)) {
return false;
}
return merge(this.getClass().getFields(), this, another);
}
public static class Name implements Serializable, Mergeable {
public String surname;
public String given;
public String additional;
public String prefix;
public String suffix;
public Name copy() {
Name dst = new Name();
dst.surname = surname;
dst.given = given;
dst.additional = additional;
dst.prefix = prefix;
dst.suffix = suffix;
return dst;
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof Name that)) {
return false;
}
surname = that.surname;
given = that.given;
additional = that.additional;
prefix = that.prefix;
suffix = that.suffix;
return true;
}
}
public static class Organization implements Serializable, Mergeable {
public String fn;
public String title;
public Organization copy() {
Organization dst = new Organization();
dst.fn = fn;
dst.title = title;
return dst;
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof Organization that)) {
return false;
}
fn = that.fn;
title = that.title;
return true;
}
}
public static class Contact implements Serializable, Comparable<Contact>, Mergeable {
public String type;
public String name;
public String uri;
private ContactType tp;
public Contact(String type, String uri) {
this(type, null, uri);
}
public Contact(String type, String name, String uri) {
this.type = type;
this.name = name;
this.uri = uri;
this.tp = stringToType(type);
}
@JsonIgnore
public ContactType getType() {
if (tp != null) {
return tp;
}
return stringToType(type);
}
public Contact copy() {
return new Contact(type, name, uri);
}
static Contact[] copyArray(Contact[] src) {
Contact[] dst = null;
if (src != null) {
dst = Arrays.copyOf(src, src.length);
for (int i=0; i<src.length;i++) {
dst[i] = src[i].copy();
}
}
return dst;
}
public static Contact[] append(Contact[] arr, Contact val) {
int insertAt;
if (arr == null) {
arr = new Contact[1];
arr[0] = val;
} else if ((insertAt = Arrays.binarySearch(arr, val)) >=0) {
if (!TYPE_OTHER.equals(val.type)) {
arr[insertAt].type = val.type;
arr[insertAt].tp = stringToType(val.type);
}
} else {
arr = Arrays.copyOf(arr, arr.length + 1);
arr[arr.length - 1] = val;
}
Arrays.sort(arr);
return arr;
}
@Override
public int compareTo(Contact c) {
return uri.compareTo(c.uri);
}
@NotNull
@Override
public String toString() {
return type + ":" + uri;
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof Contact that)) {
return false;
}
type = that.type;
name = that.name;
uri = that.uri;
tp = stringToType(type);
return true;
}
}
/**
* Generic container for image data.
*/
public static class Photo implements Serializable, Mergeable {
// Image bits (preview or full image).
public byte[] data;
// Second component of image mime type, i.e. 'png' for 'image/png'.
public String type;
// URL of the image.
public String ref;
// Intended dimensions of the full image
public Integer width, height;
// Size of the full image in bytes.
public Integer size;
public Photo() {}
/**
* The main constructor.
*
* @param bits binary image data
* @param type the specific part of image/ mime type, i.e. 'jpeg' or 'png'.
*/
public Photo(byte[] bits, String type) {
this.data = bits;
this.type = type;
}
/**
* The main constructor.
*
* @param ref Uri of the image.
* @param type the specific part of image/ mime type, i.e. 'jpeg' or 'png'.
*/
public Photo(String ref, String type) {
this.ref = ref;
this.type = type;
}
/**
* Creates a copy of a photo instance.
* @return new instance of Photo.
*/
public Photo copy() {
Photo ret = new Photo();
ret.data = data;
ret.type = type;
ret.ref = ref;
ret.width = width;
ret.height = height;
ret.size = size;
return ret;
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof Photo that)) {
return false;
}
// Direct copy. No need to check for nulls.
data = that.data;
type = that.type;
ref = that.ref;
width = that.width;
height = that.height;
size = that.size;
return true;
}
}
public static class Birthday implements Serializable, Mergeable {
// Year like 1975
Integer y;
// Month 1..12.
Integer m;
// Day 1..31.
Integer d;
public Birthday() {}
public Birthday copy() {
Birthday ret = new Birthday();
ret.y = y;
ret.m = m;
ret.d = d;
return ret;
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof Birthday that)) {
return false;
}
y = that.y;
m = that.m;
d = that.d;
return true;
}
}
}

View File

@@ -0,0 +1,39 @@
package co.tinode.tinodesdk.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.io.Serializable;
import java.util.HashMap;
import co.tinode.tinodesdk.Tinode;
/**
* Common type of the `private` field of {meta}: holds structured
* data, such as comment and archival status.
*/
public class TrustedType extends HashMap<String,Object> implements Mergeable, Serializable {
public TrustedType() {
super();
}
@JsonIgnore
public Boolean getBooleanValue(String name) {
Object val = get(name);
if (Tinode.isNull(val)) {
return false;
}
if (val instanceof Boolean) {
return (boolean) val;
}
return false;
}
@Override
public boolean merge(Mergeable another) {
if (!(another instanceof TrustedType apt)) {
return false;
}
this.putAll(apt);
return apt.size() > 0;
}
}

View File

@@ -0,0 +1,529 @@
package co.tinode.tinodesdk;
import static org.junit.Assert.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.junit.Test;
import java.util.HashMap;
import java.util.Map;
import co.tinode.tinodesdk.model.Pair;
/**
* Unit tests for static methods in Tinode class.
*/
public class TinodeTest {
/**
* Test getTypeFactory() returns non-null value
*/
@Test
public void testGetTypeFactory() {
assertNotNull(Tinode.getTypeFactory());
}
/**
* Test getJsonMapper() returns non-null value
*/
@Test
public void testGetJsonMapper() {
assertNotNull(Tinode.getJsonMapper());
}
/**
* Test isNull() with NULL_VALUE string
*/
@Test
public void testIsNull_WithNullValue() {
assertTrue(Tinode.isNull(Tinode.NULL_VALUE));
}
/**
* Test isNull() with regular string
*/
@Test
public void testIsNull_WithRegularString() {
assertFalse(Tinode.isNull("regular_string"));
}
/**
* Test isNull() with other object types
*/
@Test
public void testIsNull_WithOtherTypes() {
assertFalse(Tinode.isNull(123));
assertFalse(Tinode.isNull(45.67));
assertFalse(Tinode.isNull(new Object()));
}
/**
* Test tagSplit() with valid tag
*/
@Test
public void testTagSplit_ValidTag() {
Pair<String, String> result = Tinode.tagSplit("email:user@example.com");
assertNotNull(result);
assertEquals("email", result.first);
assertEquals("user@example.com", result.second);
}
/**
* Test tagSplit() with tag containing colon in value
*/
@Test
public void testTagSplit_TagWithColonInValue() {
Pair<String, String> result = Tinode.tagSplit("protocol:http://example.com");
assertNotNull(result);
assertEquals("protocol", result.first);
assertEquals("http://example.com", result.second);
}
/**
* Test tagSplit() with tag with spaces
*/
@Test
public void testTagSplit_TagWithSpaces() {
Pair<String, String> result = Tinode.tagSplit(" email:user@example.com ");
assertNotNull(result);
assertEquals("email", result.first);
assertEquals("user@example.com", result.second);
}
/**
* Test tagSplit() with invalid tag (no colon)
*/
@Test
public void testTagSplit_InvalidTag_NoColon() {
Pair<String, String> result = Tinode.tagSplit("notag");
assertNull(result);
}
/**
* Test tagSplit() with invalid tag (empty value)
*/
@Test
public void testTagSplit_InvalidTag_EmptyValue() {
Pair<String, String> result = Tinode.tagSplit("email:");
assertNull(result);
}
/**
* Test setUniqueTag() adding tag to null array
*/
@Test
public void testSetUniqueTag_AddToNullArray() {
String[] result = Tinode.setUniqueTag(null, "email:test@example.com");
assertNotNull(result);
assertEquals(1, result.length);
assertEquals("email:test@example.com", result[0]);
}
/**
* Test setUniqueTag() adding tag to empty array
*/
@Test
public void testSetUniqueTag_AddToEmptyArray() {
String[] result = Tinode.setUniqueTag(new String[]{}, "email:test@example.com");
assertNotNull(result);
assertEquals(1, result.length);
assertEquals("email:test@example.com", result[0]);
}
/**
* Test setUniqueTag() replacing existing tag with same prefix
*/
@Test
public void testSetUniqueTag_ReplaceExisting() {
String[] tags = {"email:old@example.com", "phone:1234567890"};
String[] result = Tinode.setUniqueTag(tags, "email:new@example.com");
assertNotNull(result);
assertEquals(2, result.length);
assertTrue(contains(result, "email:new@example.com"));
assertTrue(contains(result, "phone:1234567890"));
assertFalse(contains(result, "email:old@example.com"));
}
/**
* Test setUniqueTag() with invalid tag
*/
@Test
public void testSetUniqueTag_InvalidTag() {
String[] result = Tinode.setUniqueTag(new String[]{"email:test@example.com"}, "invalid");
assertNull(result);
}
/**
* Test clearTagPrefix() removing tags with prefix
*/
@Test
public void testClearTagPrefix_RemovePrefix() {
String[] tags = {"email:test@example.com", "phone:1234567890", "email:another@example.com"};
String[] result = Tinode.clearTagPrefix(tags, "email:");
assertNotNull(result);
assertEquals(1, result.length);
assertEquals("phone:1234567890", result[0]);
}
/**
* Test clearTagPrefix() with no matching prefix
*/
@Test
public void testClearTagPrefix_NoMatchingPrefix() {
String[] tags = {"email:test@example.com", "phone:1234567890"};
String[] result = Tinode.clearTagPrefix(tags, "fax:");
assertNotNull(result);
assertEquals(2, result.length);
}
/**
* Test clearTagPrefix() with empty array
*/
@Test
public void testClearTagPrefix_EmptyArray() {
String[] result = Tinode.clearTagPrefix(new String[]{}, "email:");
assertNull(result);
}
/**
* Test isValidTagValueFormat() with valid tag
*/
@Test
public void testIsValidTagValueFormat_ValidTag() {
assertTrue(Tinode.isValidTagValueFormat("john_doe"));
assertTrue(Tinode.isValidTagValueFormat("user123"));
assertTrue(Tinode.isValidTagValueFormat("valid-tag"));
}
/**
* Test isValidTagValueFormat() with empty string (valid by spec)
*/
@Test
public void testIsValidTagValueFormat_EmptyString() {
assertTrue(Tinode.isValidTagValueFormat(""));
}
/**
* Test isValidTagValueFormat() with null (valid by spec)
*/
@Test
public void testIsValidTagValueFormat_Null() {
assertTrue(Tinode.isValidTagValueFormat(null));
}
/**
* Test isValidTagValueFormat() with invalid characters
*/
@Test
public void testIsValidTagValueFormat_InvalidCharacters() {
assertFalse(Tinode.isValidTagValueFormat("user@example.com"));
assertFalse(Tinode.isValidTagValueFormat("tag with spaces"));
assertFalse(Tinode.isValidTagValueFormat("tag*special"));
}
/**
* Test tagByPrefix() finding tag with prefix
*/
@Test
public void testTagByPrefix_FindExisting() {
String[] tags = {"email:test@example.com", "phone:1234567890"};
String result = Tinode.tagByPrefix(tags, "email:");
assertEquals("email:test@example.com", result);
}
/**
* Test tagByPrefix() with no matching prefix
*/
@Test
public void testTagByPrefix_NoMatch() {
String[] tags = {"email:test@example.com", "phone:1234567890"};
String result = Tinode.tagByPrefix(tags, "fax:");
assertNull(result);
}
/**
* Test tagByPrefix() finding first matching tag
*/
@Test
public void testTagByPrefix_FirstMatch() {
String[] tags = {"email:first@example.com", "email:second@example.com", "phone:1234567890"};
String result = Tinode.tagByPrefix(tags, "email:");
assertEquals("email:first@example.com", result);
}
/**
* Test jsonSerialize() with simple object
*/
@Test
public void testJsonSerialize_SimpleObject() throws JsonProcessingException {
Map<String, Object> map = new HashMap<>();
map.put("key", "value");
map.put("number", 123);
String json = Tinode.jsonSerialize(map);
assertNotNull(json);
assertTrue(json.contains("key"));
assertTrue(json.contains("value"));
assertTrue(json.contains("number"));
}
/**
* Test jsonSerialize() with null object
*/
@Test
public void testJsonSerialize_NullObject() throws JsonProcessingException {
String result = Tinode.jsonSerialize(null);
assertNotNull(result);
assertEquals("null", result);
}
/**
* Test jsonSerialize() with nested object
*/
@Test
public void testJsonSerialize_NestedObject() throws JsonProcessingException {
Map<String, Object> inner = new HashMap<>();
inner.put("nested", "value");
Map<String, Object> outer = new HashMap<>();
outer.put("outer", inner);
String json = Tinode.jsonSerialize(outer);
assertNotNull(json);
assertTrue(json.contains("nested"));
}
/**
* Test newTopic() creates MeTopic for "me"
*/
@Test
public void testNewTopic_MeTopic() {
Topic topic = Tinode.newTopic(null, Tinode.TOPIC_ME, null);
assertNotNull(topic);
assertIsInstance(topic, MeTopic.class);
}
/**
* Test newTopic() creates FndTopic for "fnd"
*/
@Test
public void testNewTopic_FndTopic() {
Topic topic = Tinode.newTopic(null, Tinode.TOPIC_FND, null);
assertNotNull(topic);
assertIsInstance(topic, FndTopic.class);
}
/**
* Test newTopic() creates ComTopic for other names
*/
@Test
public void testNewTopic_ComTopic() {
Topic topic = Tinode.newTopic(null, "grpABC", null);
assertNotNull(topic);
assertIsInstance(topic, ComTopic.class);
}
/**
* Test headersForReply() creates correct map
*/
@Test
public void testHeadersForReply() {
Map<String, Object> headers = Tinode.headersForReply(42);
assertNotNull(headers);
assertTrue(headers.containsKey("reply"));
assertEquals("42", headers.get("reply"));
}
/**
* Test headersForReply() with different sequence number
*/
@Test
public void testHeadersForReply_DifferentSeq() {
Map<String, Object> headers = Tinode.headersForReply(999);
assertEquals("999", headers.get("reply"));
}
/**
* Test headersForReplacement() creates correct map
*/
@Test
public void testHeadersForReplacement() {
Map<String, Object> headers = Tinode.headersForReplacement(42);
assertNotNull(headers);
assertTrue(headers.containsKey("replace"));
assertEquals(":42", headers.get("replace"));
}
/**
* Test headersForReplacement() with different sequence number
*/
@Test
public void testHeadersForReplacement_DifferentSeq() {
Map<String, Object> headers = Tinode.headersForReplacement(999);
assertEquals(":999", headers.get("replace"));
}
/**
* Test isUrlRelative() with absolute HTTP URL
*/
@Test
public void testIsUrlRelative_AbsoluteHttp() {
assertFalse(Tinode.isUrlRelative("http://example.com/path"));
}
/**
* Test isUrlRelative() with absolute HTTPS URL
*/
@Test
public void testIsUrlRelative_AbsoluteHttps() {
assertFalse(Tinode.isUrlRelative("https://example.com/path"));
}
/**
* Test isUrlRelative() with protocol-relative URL
*/
@Test
public void testIsUrlRelative_ProtocolRelative() {
assertFalse(Tinode.isUrlRelative("//example.com/path"));
}
/**
* Test isUrlRelative() with relative path
*/
@Test
public void testIsUrlRelative_RelativePath() {
assertTrue(Tinode.isUrlRelative("/path/to/resource"));
assertTrue(Tinode.isUrlRelative("path/to/resource"));
assertTrue(Tinode.isUrlRelative("resource.txt"));
}
/**
* Test isUrlRelative() with URL starting with whitespace
*/
@Test
public void testIsUrlRelative_WithWhitespace() {
assertFalse(Tinode.isUrlRelative(" http://example.com/path"));
assertTrue(Tinode.isUrlRelative(" /path/to/resource"));
}
/**
* Test isUrlRelative() with weird schema part.
*/
@Test
public void testIsUrlRelative_WeirdSchemas() {
// Relative URLs
assertTrue(Tinode.isUrlRelative("-schema://example.com/path"));
assertTrue(Tinode.isUrlRelative("sche=ma://example.com/path"));
assertTrue(Tinode.isUrlRelative("\"sche\\ma://example.com/path"));
assertTrue(Tinode.isUrlRelative(":schema://example.com/path"));
assertTrue(Tinode.isUrlRelative("s$chema://example.com/path"));
assertTrue(Tinode.isUrlRelative("123schema://example.com/path"));
assertTrue(Tinode.isUrlRelative("s'chema://example.com/path"));
// Absolute URLs
assertFalse(Tinode.isUrlRelative("sch:ema://example.com/path"));
assertFalse(Tinode.isUrlRelative("sch--ema://example.com/path"));
assertFalse(Tinode.isUrlRelative("sc123ema://example.com/path"));
assertFalse(Tinode.isUrlRelative("aaa:::://example.com/path"));
}
/**
* Test parseTinodeUrl() with null input
*/
@Test
public void testParseTinodeUrl_NullInput() {
String result = Tinode.parseTinodeUrl(null);
assertNull(result);
}
/**
* Test parseTinodeUrl() with non-tinode URL
*/
@Test
public void testParseTinodeUrl_NonTinodeUrl() {
String url = "https://example.com";
String result = Tinode.parseTinodeUrl(url);
assertEquals(url, result);
}
/**
* Test parseTinodeUrl() with valid tinode URL
*/
@Test
public void testParseTinodeUrl_ValidTinodeUrl() {
String result = Tinode.parseTinodeUrl("tinode:///id/usrABC12345");
assertEquals("usrABC12345", result);
}
/**
* Test parseTinodeUrl() with tinode URL with host
*/
@Test
public void testParseTinodeUrl_TinodeUrlWithHost() {
String result = Tinode.parseTinodeUrl("tinode://example.com/id/usrXYZ98765");
assertEquals("usrXYZ98765", result);
}
/**
* Test parseTinodeUrl() with invalid tinode URL (missing id)
*/
@Test
public void testParseTinodeUrl_InvalidTinodeUrl() {
String url = "tinode:///user/usrABC12345";
String result = Tinode.parseTinodeUrl(url);
assertEquals(url, result);
}
/**
* Test parseTinodeUrl() with tinode URL with single part
*/
@Test
public void testParseTinodeUrl_TinodeUrlSinglePart() {
String url = "tinode://onlypart";
String result = Tinode.parseTinodeUrl(url);
assertEquals(url, result);
}
/**
* Test parseTinodeUrl() with tinode URL with no host part
*/
@Test
public void testParseTinodeUrl_TinodeUrlNoHost() {
String url = "tinode:id/usrABC12345";
String result = Tinode.parseTinodeUrl(url);
assertEquals("usrABC12345", result);
}
/**
* Test parseTinodeUrl() with tinode URL with no host and no id
*/
@Test
public void testParseTinodeUrl_TinodeUrlNoHostNoId() {
String url = "tinode:678/usrABC12345";
String result = Tinode.parseTinodeUrl(url);
assertEquals(url, result);
}
// Helper methods
/**
* Check if array contains string
*/
private boolean contains(String[] array, String value) {
for (String s : array) {
if (value.equals(s)) {
return true;
}
}
return false;
}
/**
* Assert that object is instance of class
*/
private void assertIsInstance(Object obj, Class<?> clazz) {
assertTrue("Expected " + clazz.getName() + " but got " + obj.getClass().getName(),
clazz.isInstance(obj));
}
}

View File

@@ -0,0 +1,679 @@
package co.tinode.tinodesdk.model;
import static org.junit.Assert.*;
import org.junit.Test;
public class DraftyTest {
@Test
public void testParse() {
// Basic formatting 1
Drafty actual = Drafty.parse("this is *bold*, `code` and _italic_, ~strike~");
Drafty expected = new Drafty("this is bold, code and italic, strike");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 8, 4),
new Drafty.Style("CO", 14, 4),
new Drafty.Style("EM", 23, 6),
new Drafty.Style("DL", 31, 6)
};
assertEquals("Parse 1 has failed", expected, actual);
// Basic formatting over Unicode string 2.
actual = Drafty.parse("Это *жЫрный*, `код` и _наклонный_, ~зачеркнутый~");
expected = new Drafty("Это жЫрный, код и наклонный, зачеркнутый");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 4, 6),
new Drafty.Style("CO", 12, 3),
new Drafty.Style("EM", 18, 9),
new Drafty.Style("DL", 29, 11),
};
assertEquals("Parse 2 has failed", expected, actual);
// Nested formats, string 3
actual = Drafty.parse("combined *bold and _italic_*");
expected = new Drafty("combined bold and italic");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("EM", 18, 6),
new Drafty.Style("ST", 9, 15)
};
assertEquals("Parse 3 has failed", expected, actual);
// URL, string 4
actual = Drafty.parse("an url: https://www.example.com/abc#fragment and another _www.tinode.co_");
expected = new Drafty("an url: https://www.example.com/abc#fragment and another www.tinode.co");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("EM", 57, 13),
new Drafty.Style(8, 36, 0),
new Drafty.Style(57, 13, 1)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("LN")
.putData("url", "https://www.example.com/abc#fragment"),
new Drafty.Entity("LN")
.putData("url", "http://www.tinode.co")
};
assertEquals("Parse 4 has failed", expected, actual);
// Mention, string 5
actual = Drafty.parse("this is a @mention and a #hashtag in a string");
expected = new Drafty("this is a @mention and a #hashtag in a string");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(10, 8, 0),
new Drafty.Style(25, 8, 1),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("MN").putData("val", "@mention"),
new Drafty.Entity("HT").putData("val", "#hashtag"),
};
assertEquals("Parse 5 has failed", expected, actual);
// String 6: Unicode UTF16
actual = Drafty.parse("second #юникод");
expected = new Drafty("second #юникод");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(7, 7, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("HT").putData("val", "#юникод"),
};
assertEquals("Parse 6 has failed", expected, actual);
// String 7: Unicode emoji UTF32. 👩🏽‍✈ is a medium-dark-skinned female pilot, 4 code points:
// 👩🏽‍✈ == 👩 female + 🏽 fitzpatrick skin tone + ZWJ + ✈ airplane.
actual = Drafty.parse("😀 *b1👩🏽b2* smile");
expected = new Drafty("😀 b1👩🏽b2 smile");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 2, 5),
};
assertEquals("Parse 7 - Unicode UTF32 emoji failed", expected, actual);
// String 8: two lines with emoji in the first and style in the second.
actual = Drafty.parse("first 😀 line\nsecond *line*");
expected = new Drafty("first 😀 line second line");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("BR", 12, 1),
new Drafty.Style("ST", 20, 4),
};
assertEquals("Parse 8 - Two lines with emoji failed", expected, actual);
// String 9: markup after emoji.
actual = Drafty.parse("🕯️ *bold* https://google.com");
expected = new Drafty("🕯️ bold https://google.com");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 2, 4),
new Drafty.Style(7, 18, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "https://google.com"),
};
assertEquals("Parse 9 - Markup after emoji failed", expected, actual);
// String 10: emoji with line breaks.
/* 🔴Hello🔴
🟠Hello🟠
🟡Hello🟡 */
actual = Drafty.parse("\uD83D\uDD34Hello\uD83D\uDD34\n" +
"\uD83D\uDFE0Hello\uD83D\uDFE0\n" +
"\uD83D\uDFE1Hello\uD83D\uDFE1");
expected = new Drafty("\uD83D\uDD34Hello\uD83D\uDD34 " +
"\uD83D\uDFE0Hello\uD83D\uDFE0 " +
"\uD83D\uDFE1Hello\uD83D\uDFE1");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("BR", 7, 1),
new Drafty.Style("BR", 15, 1),
};
assertEquals("Parse 10 - Emoji with line breaks failed", expected, actual);
}
@Test
public void testShorten() {
final int limit = 15;
// ------- Shorten 1
Drafty src = new Drafty("This is a plain text string.");
Drafty actual = src.shorten(limit, true);
Drafty expected = new Drafty("This is a plai…");
assertEquals("Shorten 1 has failed", expected, actual);
// ------- Shorten 2
src = new Drafty();
src.fmt = new Drafty.Style[]{
new Drafty.Style(-1, 0, 0),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("EX")
.putData("mime", "image/jpeg")
.putData("name", "hello.jpg")
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
.putData("width", 100)
.putData("height", 80),
};
actual = src.shorten(limit, true);
expected = new Drafty();
expected.fmt = new Drafty.Style[]{
new Drafty.Style(-1, 0, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("EX")
.putData("mime", "image/jpeg")
.putData("name", "hello.jpg")
.putData("width", 100)
.putData("height", 80),
};
assertEquals("Shorten 2 has failed", expected, actual);
// ------- Shorten 3
src = new Drafty("https://api.tinode.co/");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 22, 0),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
};
actual = src.shorten(limit, true);
expected = new Drafty("https://api.ti…");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 15, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
};
assertEquals("Shorten 3 has failed", expected, actual);
// ------- Shorten 4 (two references to the same entity).
src = new Drafty("Url one, two");
src.fmt = new Drafty.Style[]{
new Drafty.Style(9, 3, 0),
new Drafty.Style(4, 3, 0),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
};
actual = src.shorten(limit, true);
expected = new Drafty("Url one, two");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(4, 3, 0),
new Drafty.Style(9, 3, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
};
assertEquals("Shorten 4 has failed", expected, actual);
// ------- Shorten 5 (two different entities).
src = new Drafty("Url one, two");
src.fmt = new Drafty.Style[] {
new Drafty.Style(4, 3, 0),
new Drafty.Style(9, 3, 1),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
new Drafty.Entity("LN").putData("url", "http://example.com"),
};
actual = src.shorten(limit, true);
expected = new Drafty("Url one, two");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(4, 3, 0),
new Drafty.Style(9, 3, 1),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
new Drafty.Entity("LN").putData("url", "http://example.com"),
};
assertEquals("Shorten 5 has failed", expected, actual);
// ------- Shorten 6 (inline image)
src = new Drafty(" ");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("height", 213)
.putData("width", 638)
.putData("name", "roses.jpg")
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
.putData("mime", "image/jpeg"),
};
actual = src.shorten(limit, true);
expected = new Drafty(" ");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("height", 213)
.putData("width", 638)
.putData("name", "roses.jpg")
.putData("mime", "image/jpeg"),
};
assertEquals("Shorten 6 has failed", expected, actual);
// ------- Shorten 7 (staggered formats)
src = new Drafty("This text has staggered formats");
src.fmt = new Drafty.Style[]{
new Drafty.Style("EM", 5, 8),
new Drafty.Style("ST", 10, 13),
};
actual = src.shorten(limit, true);
expected = new Drafty("This text has …");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("EM", 5, 8),
};
assertEquals("Shorten 7 has failed", expected, actual);
// ------- Shorten 8 (multiple formatting)
src = new Drafty("This text is formatted and deleted too");
src.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 5, 4),
new Drafty.Style("EM", 13, 9),
new Drafty.Style("ST", 35, 3),
new Drafty.Style("DL", 27, 11),
};
actual = src.shorten(limit, true);
expected = new Drafty("This text is f…");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 5, 4),
new Drafty.Style("EM", 13, 2),
};
assertEquals("Shorten 8 has failed", expected, actual);
// ------- Shorten 9 (multibyte unicode)
src = new Drafty("мультибайтовый юникод");
src.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 0, 14),
new Drafty.Style("EM", 15, 6),
};
actual = src.shorten(limit, true);
expected = new Drafty("мультибайтовый…");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 0, 14),
};
assertEquals("Shorten 9 has failed", expected, actual);
// ------- Shorten 10 (quoted reply)
src = new Drafty("Alice Johnson This is a test");
src.fmt = new Drafty.Style[]{
new Drafty.Style("BR", 13,1),
new Drafty.Style(15,1, 0),
new Drafty.Style(0, 13, 1),
new Drafty.Style("QQ", 0, 16),
new Drafty.Style("BR", 16,1),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("mime","image/jpeg")
.putData("val", "<1292, bytes: /9j/4AAQSkZJ.123456789012345678901234567890123456789012345678901234567890.rehH5o6D/9k=>")
.putData("width", 25)
.putData("height", 14)
.putData("size", 968),
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
actual = src.shorten(limit, true);
expected = new Drafty("Alice Johnson …");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13, 1),
new Drafty.Style("QQ", 0, 15)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
assertEquals("Shorten 10 has failed", expected, actual);
// Emoji 1
src = Drafty.fromPlainText("a😀c😀d😀e😀f");
actual = src.shorten(5, false);
expected = Drafty.fromPlainText("a😀c😀…");
assertEquals("Shorten Emoji 1 has failed", expected, actual);
// Emoji 2. 👩🏽‍✈ is a medium-dark-skinned female pilot, 4 code points:
// '👩🏽‍✈' == 👩 female + 🏽 fitzpatrick skin tone + ZWJ + ✈ airplane.
// AndroidStudio shows '👩🏽‍✈️' instead of '👩🏽‍✈' below. Ignore it.
src = Drafty.fromPlainText("😀 b1👩🏽b2 smile");
actual = src.shorten(6, false);
expected = Drafty.fromPlainText("😀 b1👩🏽");
assertEquals("Shorten Emoji 2 has failed", expected, actual);
// String 10: compound emoji.
/* 🔴Hello🔴 🟠Hello🟠 */
src = Drafty.parse("\uD83D\uDD34Hello\uD83D\uDD34 " +
"\uD83D\uDFE0Hello\uD83D\uDFE0");
actual = src.shorten(14, false);
expected = new Drafty("\uD83D\uDD34Hello\uD83D\uDD34 " +
"\uD83D\uDFE0Hell…");
assertEquals("Shorten Emoji 3 - Compound emoji has failed", expected, actual);
}
@Test
public void testForward() {
// ------- Forward 1 (unchanged).
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13,1),
new Drafty.Style("QQ", 0, 38)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
Drafty actual = src.forwardedContent();
Drafty expected = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13,1),
new Drafty.Style("QQ", 0, 38)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
assertEquals("Forward 1 has failed", expected, actual);
// ------- Forward 2 (mention stripped).
src = new Drafty("➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 15, 0),
new Drafty.Style("BR", 15,1),
new Drafty.Style(16, 13, 1),
new Drafty.Style("BR", 29,1),
new Drafty.Style("QQ", 16, 36)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN").putData("val", "usr123abcDE"),
new Drafty.Entity("MN").putData("val", "usr123abcDE")
};
actual = src.forwardedContent();
expected = new Drafty("Alice Johnson This is a simple replyThis is a reply to reply");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13,1),
new Drafty.Style("QQ", 0, 36)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("MN").putData("val", "usr123abcDE")
};
assertEquals("Forward 2 has failed", expected, actual);
}
@Test
public void testPreview() {
// ------- Preview 1.
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13,1),
new Drafty.Style("QQ", 0, 38)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
Drafty actual = src.preview(25);
Drafty expected = new Drafty(" This is a Reply -> Forw…");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("QQ", 0, 1)
};
assertEquals("Preview 1 has failed", expected, actual);
// ------- Preview 2.
src = new Drafty("➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 15, 0),
new Drafty.Style("BR", 15,1),
new Drafty.Style(16, 13, 1),
new Drafty.Style("BR", 29,1),
new Drafty.Style("QQ", 16, 36)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN").putData("val", "usr123abcDE"),
new Drafty.Entity("MN").putData("val", "usr123abcDE")
};
actual = src.preview(25);
expected = new Drafty("➦ This is a reply to re…");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
new Drafty.Style("QQ", 2, 1)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
assertEquals("Preview 2 has failed", expected, actual);
}
@Test
public void testReply() {
// --------- Reply 1
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13,1),
new Drafty.Style("QQ", 0, 38)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
Drafty actual = src.replyContent(25, 3);
Drafty expected = new Drafty("This is a Reply -> Forwa…");
assertEquals("Reply 1 has failed", expected, actual);
// ----------- Reply 2
src = new Drafty("➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 15, 0),
new Drafty.Style("BR", 15,1),
new Drafty.Style(16, 13, 1),
new Drafty.Style("BR", 29,1),
new Drafty.Style("QQ", 16, 36)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN").putData("val", "usr123abcDE"),
new Drafty.Entity("MN").putData("val", "usr123abcDE")
};
actual = src.replyContent(25, 3);
expected = new Drafty("➦ This is a reply to rep…");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("MN", 0, 1)
};
assertEquals("Reply 2 has failed", expected, actual);
// ----------- Reply 3
src = new Drafty("Message with attachment");
src.fmt = new Drafty.Style[]{
new Drafty.Style(-1, 0, 0),
new Drafty.Style("ST", 8,4)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("EX")
.putData("mime","image/jpeg")
.putData("val", "<1292, bytes: /9j/4AAQSkZJ.123456789012345678901234567890123456789012345678901234567890.rehH5o6D/9k=>")
.putData("width", 25)
.putData("height", 14)
.putData("size", 968)
.putData("name", "hello.jpg"),
};
actual = src.replyContent(25, 3);
expected = new Drafty("Message with attachment ");
expected.fmt = new Drafty.Style[]{
new Drafty.Style("ST", 8,4),
new Drafty.Style(23, 1, 0)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("EX")
.putData("mime","image/jpeg")
.putData("width", 25)
.putData("height", 14)
.putData("size", 968)
.putData("name", "hello.jpg"),
};
assertEquals("Reply 3 has failed", expected, actual);
// ----------- Reply 4
src = new Drafty();
src.fmt = new Drafty.Style[]{
new Drafty.Style(-1, 0, 0)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("EX")
.putData("mime","image/jpeg")
.putData("val", "<1292, bytes: /9j/4AAQSkZJ.123456789012345678901234567890123456789012345678901234567890.rehH5o6D/9k=>")
.putData("width", 25)
.putData("height", 14)
.putData("size", 968)
.putData("name", "hello.jpg"),
};
actual = src.replyContent(25, 3);
expected = new Drafty(" ");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0)
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("EX")
.putData("mime","image/jpeg")
.putData("width", 25)
.putData("height", 14)
.putData("size", 968)
.putData("name", "hello.jpg"),
};
assertEquals("Reply 4 has failed", expected, actual);
// ------- Reply 5 (inline image with in-band bits only)
src = new Drafty(" ");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("height", 213)
.putData("width", 638)
.putData("name", "roses.jpg")
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
.putData("mime", "image/jpeg"),
};
actual = src.replyContent(25, 3);
expected = new Drafty(" ");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("height", 213)
.putData("width", 638)
.putData("name", "roses.jpg")
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
.putData("mime", "image/jpeg"),
};
assertEquals("Reply 5 has failed", expected, actual);
// ------- Reply 6 (inline image with in-band preview and out of band reference)
src = new Drafty(" ");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("height", 213)
.putData("width", 638)
.putData("name", "roses.jpg")
.putData("val", "<3992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
.putData("ref", "/v0/file/s/77A4SDFXfzY.jpe")
.putData("mime", "image/jpeg"),
};
actual = src.replyContent(25, 3);
expected = new Drafty(" ");
expected.fmt = new Drafty.Style[]{
new Drafty.Style(0, 1, 0),
};
expected.ent = new Drafty.Entity[]{
new Drafty.Entity("IM")
.putData("height", 213)
.putData("width", 638)
.putData("name", "roses.jpg")
.putData("val", "<3992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
.putData("mime", "image/jpeg"),
};
assertEquals("Reply 6 has failed", expected, actual);
}
@Test
public void testFormat() {
// --------- Format 1
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
src.fmt = new Drafty.Style[]{
new Drafty.Style(0, 13, 0),
new Drafty.Style("BR", 13,1),
new Drafty.Style("ST", 0, 38)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("MN")
.putData("val", "usr123abcDE")
};
String actual = src.toMarkdown(false);
String expected = "*@Alice Johnson\nThis is a reply to reply*This is a Reply -> Forward -> Reply.";
assertEquals("Format 1 has failed", expected, actual);
// --------- Format 2
src = new Drafty("an url: https://www.example.com/abc#fragment and another www.tinode.co");
src.fmt = new Drafty.Style[]{
new Drafty.Style("EM", 57, 13),
new Drafty.Style(8, 36, 0),
new Drafty.Style(57, 13, 1)
};
src.ent = new Drafty.Entity[]{
new Drafty.Entity("LN")
.putData("url", "https://www.example.com/abc#fragment"),
new Drafty.Entity("LN")
.putData("url", "http://www.tinode.co")
};
actual = src.toMarkdown(false);
expected = "an url: [https://www.example.com/abc#fragment](https://www.example.com/abc#fragment) "+
"and another _[www.tinode.co](http://www.tinode.co)_";
assertEquals("Format 2 has failed", expected, actual);
}
@Test
public void testInvalid() {
// --------- Invalid 1
Drafty src = new Drafty("Null style element");
src.fmt = new Drafty.Style[]{
null,
new Drafty.Style("EM", 5,5)
};
String actual = src.toMarkdown(false);
String expected = "Null _style_ element";
assertEquals("Invalid 1 has failed", expected, actual);
// --------- Invalid 2
src = new Drafty("Missing entity");
src.fmt = new Drafty.Style[]{
new Drafty.Style(8,4, 0)
};
actual = src.toMarkdown(false);
expected = "Missing entity";
assertEquals("Invalid 2 has failed", expected, actual);
// --------- Invalid 3
src = new Drafty("Missing entity in the middle");
src.fmt = new Drafty.Style[]{
new Drafty.Style(8,4, 0),
new Drafty.Style(15,2, 1)
};
src.ent = new Drafty.Entity[]{
null,
new Drafty.Entity("LN")
.putData("url", "http://www.tinode.co")
};
actual = src.toMarkdown(false);
expected = "Missing entity [in](http://www.tinode.co) the middle";
assertEquals("Invalid 3 has failed", expected, actual);
}
}

View File

@@ -0,0 +1,210 @@
package co.tinode.tinodesdk.model;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
public class MsgRangeTest {
@Test
public void testRange() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(1, 5);
MsgRange r3 = new MsgRange(2, 4);
MsgRange r4 = new MsgRange(3, 6);
assertEquals(r1, r2);
assertNotEquals(r1, r3);
assertNotEquals(r1, r4);
assertNotEquals(r3, r4);
}
@Test
public void testRangeEnclosing() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(4, 8);
MsgRange r3 = new MsgRange(6, 10);
MsgRange e1 = MsgRange.enclosing(new MsgRange[]{r1, r2, r3});
MsgRange e2 = MsgRange.enclosing(new MsgRange[]{r1, r3});
MsgRange e3 = MsgRange.enclosing(new MsgRange[]{r2, r3});
assertEquals(new MsgRange(1, 10), e1);
assertEquals(new MsgRange(1, 10), e2);
assertEquals(new MsgRange(4, 10), e3);
}
@Test
public void testRangeNormalize() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(3, 3);
MsgRange r3 = new MsgRange(3, -50);
r1.normalize();
r2.normalize();
r3.normalize();
assertEquals(new MsgRange(1, 5), r1);
assertEquals(new MsgRange(3), r2);
assertEquals(new MsgRange(3), r3);
}
@Test
public void testRangeToString() {
MsgRange r1 = new MsgRange(1, 5);
assertEquals("{low: 1, hi: 5}", r1.toString());
MsgRange r2 = new MsgRange(3);
assertEquals("{low: 3}", r2.toString());
}
@Test
public void testRangeToRanges() {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(5);
list.add(6);
MsgRange[] ranges = MsgRange.toRanges(list);
assertEquals(2, ranges.length);
assertEquals(new MsgRange(1, 4), ranges[0]);
assertEquals(new MsgRange(5, 7), ranges[1]);
}
@Test
public void testRangeToRangesEmpty() {
List<Integer> list = new ArrayList<>();
MsgRange[] ranges = MsgRange.toRanges(list);
assertNull(ranges);
int[] arr = new int[0];
ranges = MsgRange.toRanges(arr);
assertNull(ranges);
}
@Test
public void testRangeToRangesSingle() {
List<Integer> list = new ArrayList<>();
list.add(1);
MsgRange[] ranges = MsgRange.toRanges(list);
assertEquals(1, ranges.length);
assertEquals(new MsgRange(1), ranges[0]);
int[] arr = new int[]{1};
ranges = MsgRange.toRanges(arr);
assertEquals(1, ranges.length);
assertEquals(new MsgRange(1), ranges[0]);
}
@Test
public void testTryExtending() {
MsgRange r1 = new MsgRange(1, 5);
assertTrue(r1.tryExtending(5));
assertEquals(new MsgRange(1, 6), r1);
MsgRange r2 = new MsgRange(1, 5);
assertFalse(r2.tryExtending(0));
assertEquals(new MsgRange(1, 5), r2);
MsgRange r3 = new MsgRange(1, 5);
assertFalse(r3.tryExtending(6));
assertEquals(new MsgRange(1, 5), r3);
}
@Test
public void testCollapse() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(3, 7);
MsgRange r3 = new MsgRange(6, 10);
List<MsgRange> ranges = new ArrayList<>();
ranges.add(r1);
ranges.add(r2);
ranges.add(r3);
MsgRange[] collapsed = MsgRange.collapse(ranges.toArray(new MsgRange[]{}));
assertEquals(1, collapsed.length);
assertEquals(new MsgRange(1, 10), collapsed[0]);
}
@Test
public void testCollapseEmpty() {
MsgRange[] ranges = new MsgRange[]{};
MsgRange[] collapsed = MsgRange.collapse(ranges);
assertEquals(0, collapsed.length);
}
@Test
public void testCollapseSingle() {
MsgRange r1 = new MsgRange(1, 5);
List<MsgRange> ranges = new ArrayList<>();
ranges.add(r1);
MsgRange[] collapsed = MsgRange.collapse(ranges.toArray(new MsgRange[]{}));
assertEquals(1, collapsed.length);
assertEquals(new MsgRange(1, 5), collapsed[0]);
}
@Test
public void testCollapseNonOverlapping() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(7, 10);
MsgRange r3 = new MsgRange(11, 15);
List<MsgRange> ranges = new ArrayList<>();
ranges.add(r1);
ranges.add(r2);
ranges.add(r3);
MsgRange[] collapsed = MsgRange.collapse(ranges.toArray(new MsgRange[]{}));
assertEquals(3, collapsed.length);
assertEquals(new MsgRange(1, 5), collapsed[0]);
assertEquals(new MsgRange(7, 10), collapsed[1]);
assertEquals(new MsgRange(11, 15), collapsed[2]);
}
@Test
public void testGaps() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(7, 10);
MsgRange r3 = new MsgRange(11, 15);
List<MsgRange> ranges = new ArrayList<>();
ranges.add(r1);
ranges.add(r2);
ranges.add(r3);
MsgRange[] gaps = MsgRange.gaps(ranges.toArray(new MsgRange[]{}));
assertEquals(2, gaps.length);
assertEquals(new MsgRange(5, 7), gaps[0]);
assertEquals(new MsgRange(10, 11), gaps[1]);
}
@Test
public void testClip() {
MsgRange r1 = new MsgRange(1, 5);
MsgRange r2 = new MsgRange(3, 7);
MsgRange r3 = new MsgRange(1, 10);
MsgRange[] clipped = MsgRange.clip(r1, r2);
assertEquals(1, clipped.length);
assertEquals(new MsgRange(1, 3), clipped[0]);
clipped = MsgRange.clip(r1, r3);
assertEquals(0, clipped.length);
clipped = MsgRange.clip(r2, r1);
assertEquals(1, clipped.length);
assertEquals(new MsgRange(3, 5), clipped[0]);
clipped = MsgRange.clip(r3, r2);
assertEquals(2, clipped.length);
assertEquals(new MsgRange(1, 3), clipped[0]);
assertEquals(new MsgRange(7, 10), clipped[1]);
}
}