From acf80bddd07d5579cdb20a888b3f9e99e53030a5 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Fri, 28 Feb 2014 18:46:01 +0100 Subject: rebranding --- src/eu/siacs/conversations/utils/DNSHelper.java | 93 +++++++++ .../siacs/conversations/utils/MessageParser.java | 149 +++++++++++++++ .../utils/OnPhoneContactsLoadedListener.java | 9 + src/eu/siacs/conversations/utils/PhoneHelper.java | 87 +++++++++ src/eu/siacs/conversations/utils/SASL.java | 24 +++ src/eu/siacs/conversations/utils/UIHelper.java | 210 +++++++++++++++++++++ src/eu/siacs/conversations/utils/Validator.java | 14 ++ 7 files changed, 586 insertions(+) create mode 100644 src/eu/siacs/conversations/utils/DNSHelper.java create mode 100644 src/eu/siacs/conversations/utils/MessageParser.java create mode 100644 src/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java create mode 100644 src/eu/siacs/conversations/utils/PhoneHelper.java create mode 100644 src/eu/siacs/conversations/utils/SASL.java create mode 100644 src/eu/siacs/conversations/utils/UIHelper.java create mode 100644 src/eu/siacs/conversations/utils/Validator.java (limited to 'src/eu/siacs/conversations/utils') diff --git a/src/eu/siacs/conversations/utils/DNSHelper.java b/src/eu/siacs/conversations/utils/DNSHelper.java new file mode 100644 index 00000000..46fd6928 --- /dev/null +++ b/src/eu/siacs/conversations/utils/DNSHelper.java @@ -0,0 +1,93 @@ +package eu.siacs.conversations.utils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.util.Random; + +import android.os.Bundle; +import android.util.Log; + +public class DNSHelper { + public static Bundle getSRVRecord(String host) { + Bundle namePort = new Bundle(); + try { + String[] hostParts = host.split("\\."); + byte[] transId = new byte[2]; + Random random = new Random(); + random.nextBytes(transId); + byte[] header = { 0x01, 0x20, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x0c, 0x5f, 0x78, 0x6d, 0x70, 0x70, 0x2d, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x04, 0x5f, 0x74, 0x63, 0x70 }; + byte[] rest = { 0x00, 0x00, 0x21, 0x00, 0x01, 0x00, 0x00, 0x29, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + output.write(transId); + output.write(header); + for (int i = 0; i < hostParts.length; ++i) { + char[] tmpChars = hostParts[i].toCharArray(); + byte[] tmp = new byte[tmpChars.length]; + for (int j = 0; j < tmpChars.length; ++j) { + tmp[j] = (byte) tmpChars[j]; + } + output.write(tmp.length); + output.write(tmp); + } + output.write(rest); + byte[] sendPaket = output.toByteArray(); + byte[] addr = { 0x8, 0x8, 0x8, 0x8 }; + int realLenght = sendPaket.length - 11; + DatagramPacket packet = new DatagramPacket(sendPaket, + sendPaket.length, InetAddress.getByAddress(addr), 53); + DatagramSocket datagramSocket = new DatagramSocket(); + datagramSocket.send(packet); + byte[] receiveData = new byte[1024]; + + DatagramPacket receivePacket = new DatagramPacket(receiveData, + receiveData.length); + datagramSocket.setSoTimeout(2000); + datagramSocket.receive(receivePacket); + if (receiveData[3]!=-128) { + namePort.putString("error", "nosrv"); + return namePort; + } + namePort.putInt("port",calcPort(receiveData[realLenght + 16], + receiveData[realLenght + 17])); + int i = realLenght + 18; + int wordLenght = 0; + StringBuilder builder = new StringBuilder(); + while (receiveData[i] != 0) { + if (wordLenght > 0) { + builder.append((char) receiveData[i]); + --wordLenght; + } else { + wordLenght = receiveData[i]; + builder.append("."); + } + ++i; + } + builder.replace(0, 1, ""); + namePort.putString("name",builder.toString()); + } catch (IOException e) { + Log.d("xmppService","gut" + e.getMessage()); + } + return namePort; + } + + static int calcPort(byte hb, byte lb) { + return ((int) hb << 8) | ((int) lb & 0xFF); + } + + final protected static char[] hexArray = "0123456789ABCDEF".toCharArray(); + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for ( int j = 0; j < bytes.length; j++ ) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } +} diff --git a/src/eu/siacs/conversations/utils/MessageParser.java b/src/eu/siacs/conversations/utils/MessageParser.java new file mode 100644 index 00000000..dc0cd35c --- /dev/null +++ b/src/eu/siacs/conversations/utils/MessageParser.java @@ -0,0 +1,149 @@ +package eu.siacs.conversations.utils; + +import java.util.List; + +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionStatus; +import android.util.Log; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xmpp.MessagePacket; + +public class MessageParser { + + protected static final String LOGTAG = "xmppService"; + + public static Message parsePlainTextChat(MessagePacket packet, Account account, XmppConnectionService service) { + String[] fromParts = packet.getFrom().split("/"); + Conversation conversation = service.findOrCreateConversation(account, fromParts[0],false); + String body = packet.getBody(); + return new Message(conversation, packet.getFrom(), body, Message.ENCRYPTION_NONE, Message.STATUS_RECIEVED); + } + + public static Message parsePgpChat(String pgpBody, MessagePacket packet, Account account, XmppConnectionService service) { + String[] fromParts = packet.getFrom().split("/"); + Conversation conversation = service.findOrCreateConversation(account, fromParts[0],false); + return new Message(conversation, packet.getFrom(), pgpBody, Message.ENCRYPTION_PGP, Message.STATUS_RECIEVED); + } + + public static Message parseOtrChat(MessagePacket packet, Account account, XmppConnectionService service) { + String[] fromParts = packet.getFrom().split("/"); + Conversation conversation = service.findOrCreateConversation(account, fromParts[0],false); + String body = packet.getBody(); + if (!conversation.hasValidOtrSession()) { + conversation.startOtrSession(service.getApplicationContext(), fromParts[1]); + } + try { + Session otrSession = conversation.getOtrSession(); + SessionStatus before = otrSession + .getSessionStatus(); + body = otrSession.transformReceiving(body); + SessionStatus after = otrSession.getSessionStatus(); + if ((before != after) + && (after == SessionStatus.ENCRYPTED)) { + Log.d(LOGTAG, "otr session etablished"); + List messages = conversation + .getMessages(); + for (int i = 0; i < messages.size(); ++i) { + Message msg = messages.get(i); + if ((msg.getStatus() == Message.STATUS_UNSEND) + && (msg.getEncryption() == Message.ENCRYPTION_OTR)) { + MessagePacket outPacket = service.prepareMessagePacket( + account, msg, otrSession); + msg.setStatus(Message.STATUS_SEND); + service.databaseBackend.updateMessage(msg); + account.getXmppConnection() + .sendMessagePacket(outPacket); + } + } + if (service.convChangedListener!=null) { + service.convChangedListener.onConversationListChanged(); + } + } else if ((before != after) && (after == SessionStatus.FINISHED)) { + conversation.resetOtrSession(); + Log.d(LOGTAG,"otr session stoped"); + } + } catch (Exception e) { + Log.d(LOGTAG, "error receiving otr. resetting"); + conversation.resetOtrSession(); + return null; + } + if (body == null) { + return null; + } + return new Message(conversation, packet.getFrom(), body, Message.ENCRYPTION_OTR,Message.STATUS_RECIEVED); + } + + public static Message parseGroupchat(MessagePacket packet, Account account, XmppConnectionService service) { + int status; + String[] fromParts = packet.getFrom().split("/"); + Conversation conversation = service.findOrCreateConversation(account, fromParts[0],true); + if ((fromParts.length == 1) || (packet.hasChild("subject"))) { + return null; + } + String counterPart = fromParts[1]; + if (counterPart.equals(account.getUsername())) { + status = Message.STATUS_SEND; + } else { + status = Message.STATUS_RECIEVED; + } + return new Message(conversation, counterPart, packet.getBody(), Message.ENCRYPTION_NONE, status); + } + + public static Message parseCarbonMessage(MessagePacket packet, + Account account, XmppConnectionService service) { + // TODO Auto-generated method stub + int status; + String fullJid; + Element forwarded; + if (packet.hasChild("received")) { + forwarded = packet.findChild("received").findChild( + "forwarded"); + status = Message.STATUS_RECIEVED; + } else if (packet.hasChild("sent")) { + forwarded = packet.findChild("sent").findChild( + "forwarded"); + status = Message.STATUS_SEND; + } else { + return null; + } + Element message = forwarded.findChild("message"); + if ((message == null) || (!message.hasChild("body"))) + return null; // either malformed or boring + if (status == Message.STATUS_RECIEVED) { + fullJid = message.getAttribute("from"); + } else { + fullJid = message.getAttribute("to"); + } + String[] parts = fullJid.split("/"); + Conversation conversation = service.findOrCreateConversation(account, parts[0],false); + return new Message(conversation,fullJid, message.findChild("body").getContent(), Message.ENCRYPTION_NONE,status); + } + + public static Message parseError(MessagePacket packet, Account account, XmppConnectionService service) { + + String[] fromParts = packet.getFrom().split("/"); + Conversation conversation = service.findOrCreateConversation(account, fromParts[0],false); + Element error = packet.findChild("error"); + String errorName = error.getChildren().get(0).getName(); + String displayError; + if (errorName.equals("service-unavailable")) { + displayError = "Contact is offline and does not have offline storage"; + } else { + displayError = errorName.replace("-", " "); + } + return new Message(conversation, packet.getFrom(), displayError, Message.ENCRYPTION_NONE, Message.STATUS_ERROR); + } + + public static String getPgpBody(MessagePacket packet) { + for(Element child : packet.getChildren()) { + if (child.getName().equals("x")&&child.getAttribute("xmlns").equals("jabber:x:encrypted")) { + return child.getContent(); + } + } + return null; + } +} diff --git a/src/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java b/src/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java new file mode 100644 index 00000000..fa8cea04 --- /dev/null +++ b/src/eu/siacs/conversations/utils/OnPhoneContactsLoadedListener.java @@ -0,0 +1,9 @@ +package eu.siacs.conversations.utils; + +import java.util.Hashtable; + +import android.os.Bundle; + +public interface OnPhoneContactsLoadedListener { + public void onPhoneContactsLoaded(Hashtable phoneContacts); +} diff --git a/src/eu/siacs/conversations/utils/PhoneHelper.java b/src/eu/siacs/conversations/utils/PhoneHelper.java new file mode 100644 index 00000000..e28f817e --- /dev/null +++ b/src/eu/siacs/conversations/utils/PhoneHelper.java @@ -0,0 +1,87 @@ +package eu.siacs.conversations.utils; + +import java.util.Hashtable; + +import android.app.Activity; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.content.Loader.OnLoadCompleteListener; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Looper; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Profile; + +public class PhoneHelper { + + public static void loadPhoneContacts(Context context, final OnPhoneContactsLoadedListener listener) { + if (Looper.myLooper()==null) { + Looper.prepare(); + } + final Looper mLooper = Looper.myLooper(); + final Hashtable phoneContacts = new Hashtable(); + + final String[] PROJECTION = new String[] { + ContactsContract.Data._ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.Data.PHOTO_THUMBNAIL_URI, + ContactsContract.Data.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Im.DATA }; + + final String SELECTION = "(" + ContactsContract.Data.MIMETYPE + "=\"" + + ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE + + "\") AND (" + ContactsContract.CommonDataKinds.Im.PROTOCOL + + "=\"" + ContactsContract.CommonDataKinds.Im.PROTOCOL_JABBER + + "\")"; + + CursorLoader mCursorLoader = new CursorLoader(context, + ContactsContract.Data.CONTENT_URI, PROJECTION, SELECTION, null, + null); + mCursorLoader.registerListener(0, new OnLoadCompleteListener() { + + @Override + public void onLoadComplete(Loader arg0, Cursor cursor) { + while (cursor.moveToNext()) { + Bundle contact = new Bundle(); + contact.putInt("phoneid", cursor.getInt(cursor + .getColumnIndex(ContactsContract.Data._ID))); + contact.putString( + "displayname", + cursor.getString(cursor + .getColumnIndex(ContactsContract.Data.DISPLAY_NAME))); + contact.putString( + "photouri", + cursor.getString(cursor + .getColumnIndex(ContactsContract.Data.PHOTO_THUMBNAIL_URI))); + contact.putString("lookup",cursor.getString(cursor + .getColumnIndex(ContactsContract.Data.LOOKUP_KEY))); + phoneContacts.put( + cursor.getString(cursor + .getColumnIndex(ContactsContract.CommonDataKinds.Im.DATA)), + contact); + } + if (listener!=null) { + listener.onPhoneContactsLoaded(phoneContacts); + } + mLooper.quit(); + } + }); + mCursorLoader.startLoading(); + } + + public static Uri getSefliUri(Activity activity) { + String[] mProjection = new String[] { Profile._ID, + Profile.PHOTO_THUMBNAIL_URI }; + Cursor mProfileCursor = activity.getContentResolver().query( + Profile.CONTENT_URI, mProjection, null, null, null); + + if (mProfileCursor.getCount()==0) { + return null; + } else { + mProfileCursor.moveToFirst(); + return Uri.parse(mProfileCursor.getString(1)); + } + } +} diff --git a/src/eu/siacs/conversations/utils/SASL.java b/src/eu/siacs/conversations/utils/SASL.java new file mode 100644 index 00000000..cda1f97b --- /dev/null +++ b/src/eu/siacs/conversations/utils/SASL.java @@ -0,0 +1,24 @@ +package eu.siacs.conversations.utils; + +import android.util.Base64; + +public class SASL { + public static String plain(String username, String password) { + byte[] userBytes = username.getBytes(); + int userLenght = userBytes.length; + byte[] passwordBytes = password.getBytes(); + byte[] saslBytes = new byte[userBytes.length+passwordBytes.length+2]; + saslBytes[0] = 0x0; + for(int i = 1; i < saslBytes.length; ++i) { + if (i<=userLenght) { + saslBytes[i] = userBytes[i-1]; + } else if (i==userLenght+1) { + saslBytes[i] = 0x0; + } else { + saslBytes[i] = passwordBytes[i-(userLenght+2)]; + } + } + + return Base64.encodeToString(saslBytes, Base64.DEFAULT); + } +} diff --git a/src/eu/siacs/conversations/utils/UIHelper.java b/src/eu/siacs/conversations/utils/UIHelper.java new file mode 100644 index 00000000..f79fe14c --- /dev/null +++ b/src/eu/siacs/conversations/utils/UIHelper.java @@ -0,0 +1,210 @@ +package eu.siacs.conversations.utils; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.ui.ConversationActivity; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.net.Uri; +import android.preference.PreferenceManager; +import android.provider.ContactsContract.Contacts; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.TaskStackBuilder; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.QuickContactBadge; +import android.widget.TextView; + +public class UIHelper { + public static String readableTimeDifference(long time) { + if (time == 0) { + return "just now"; + } + Date date = new Date(time); + long difference = (System.currentTimeMillis() - time) / 1000; + if (difference < 60) { + return "just now"; + } else if (difference < 60 * 10) { + return difference / 60 + " min ago"; + } else if (difference < 60 * 60 * 24) { + SimpleDateFormat sdf = new SimpleDateFormat("HH:mm"); + return sdf.format(date); + } else { + SimpleDateFormat sdf = new SimpleDateFormat("MM/dd"); + return sdf.format(date); + } + } + + public static Bitmap getUnknownContactPicture(String name, int size) { + String firstLetter = name.substring(0, 1).toUpperCase(); + + int holoColors[] = { 0xFF1da9da, 0xFFb368d9, 0xFF83b600, 0xFFffa713, + 0xFFe92727 }; + + int color = holoColors[Math.abs(name.hashCode()) % holoColors.length]; + + Bitmap bitmap = Bitmap + .createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + bitmap.eraseColor(color); + + Paint paint = new Paint(); + paint.setColor(0xffe5e5e5); + paint.setTextSize((float) (size * 0.9)); + paint.setAntiAlias(true); + Rect rect = new Rect(); + paint.getTextBounds(firstLetter, 0, 1, rect); + float width = paint.measureText(firstLetter); + canvas.drawText(firstLetter, (size / 2) - (width / 2), (size / 2) + + (rect.height() / 2), paint); + + return bitmap; + } + + public static Bitmap getErrorPicture(int size) { + Bitmap bitmap = Bitmap + .createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + bitmap.eraseColor(0xFFe92727); + + Paint paint = new Paint(); + paint.setColor(0xffe5e5e5); + paint.setTextSize((float) (size * 0.9)); + paint.setAntiAlias(true); + Rect rect = new Rect(); + paint.getTextBounds("!", 0, 1, rect); + float width = paint.measureText("!"); + canvas.drawText("!", (size / 2) - (width / 2), (size / 2) + + (rect.height() / 2), paint); + + return bitmap; + } + + public static Notification getUnreadMessageNotification(Context context, + Conversation conversation) { + + SharedPreferences sharedPref = PreferenceManager + .getDefaultSharedPreferences(context); + String ringtone = sharedPref.getString("notification_ringtone", null); + + Resources res = context.getResources(); + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder( + context); + mBuilder.setLargeIcon(UIHelper.getUnknownContactPicture(conversation + .getName(), (int) res + .getDimension(android.R.dimen.notification_large_icon_width))); + mBuilder.setContentTitle(conversation.getName()); + mBuilder.setTicker(conversation.getLatestMessage().getBody().trim()); + StringBuilder bigText = new StringBuilder(); + List messages = conversation.getMessages(); + String firstLine = ""; + for(int i = messages.size() -1; i >= 0; --i) { + if (!messages.get(i).isRead()) { + if (i == messages.size() -1 ) { + firstLine = messages.get(i).getBody().trim(); + bigText.append(firstLine); + } else { + firstLine = messages.get(i).getBody().trim(); + bigText.insert(0, firstLine+"\n"); + } + } else { + break; + } + } + mBuilder.setContentText(firstLine); + mBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(bigText.toString())); + mBuilder.setSmallIcon(R.drawable.notification); + mBuilder.setLights(0xffffffff, 2000, 4000); + if (ringtone != null) { + mBuilder.setSound(Uri.parse(ringtone)); + } + + TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + stackBuilder.addParentStack(ConversationActivity.class); + + Intent viewConversationIntent = new Intent(context, + ConversationActivity.class); + viewConversationIntent.setAction(Intent.ACTION_VIEW); + viewConversationIntent.putExtra(ConversationActivity.CONVERSATION, + conversation.getUuid()); + viewConversationIntent.setType(ConversationActivity.VIEW_CONVERSATION); + + stackBuilder.addNextIntent(viewConversationIntent); + + PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, + PendingIntent.FLAG_UPDATE_CURRENT); + + mBuilder.setContentIntent(resultPendingIntent); + return mBuilder.build(); + } + + public static void prepareContactBadge(final Activity activity, + QuickContactBadge badge, final Contact contact) { + if (contact.getSystemAccount()!=null) { + String[] systemAccount = contact.getSystemAccount().split("#"); + long id = Long.parseLong(systemAccount[0]); + badge.assignContactUri(Contacts.getLookupUri(id, systemAccount[1])); + + if (contact.getProfilePhoto() != null) { + badge.setImageURI(Uri.parse(contact.getProfilePhoto())); + } else { + badge.setImageBitmap(UIHelper.getUnknownContactPicture(contact.getDisplayName(), 400)); + } + } else { + badge.setImageBitmap(UIHelper.getUnknownContactPicture(contact.getDisplayName(), 400)); + } + + } + + public static AlertDialog getVerifyFingerprintDialog(final ConversationActivity activity,final Conversation conversation, final LinearLayout msg) { + final Contact contact = conversation.getContact(); + final Account account = conversation.getAccount(); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle("Verify fingerprint"); + LayoutInflater inflater = activity.getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_verify_otr, null); + TextView jid = (TextView) view.findViewById(R.id.verify_otr_jid); + TextView fingerprint = (TextView) view.findViewById(R.id.verify_otr_fingerprint); + TextView yourprint = (TextView) view.findViewById(R.id.verify_otr_yourprint); + + jid.setText(contact.getJid()); + fingerprint.setText(conversation.getOtrFingerprint()); + yourprint.setText(account.getOtrFingerprint()); + builder.setNegativeButton("Cancel", null); + builder.setPositiveButton("Verify", new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + contact.addOtrFingerprint(conversation.getOtrFingerprint()); + msg.setVisibility(View.GONE); + activity.xmppConnectionService.updateContact(contact); + } + }); + builder.setView(view); + return builder.create(); + } +} diff --git a/src/eu/siacs/conversations/utils/Validator.java b/src/eu/siacs/conversations/utils/Validator.java new file mode 100644 index 00000000..fce953ae --- /dev/null +++ b/src/eu/siacs/conversations/utils/Validator.java @@ -0,0 +1,14 @@ +package eu.siacs.conversations.utils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Validator { + public static final Pattern VALID_JID = + Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE); + + public static boolean isValidJid(String jid) { + Matcher matcher = VALID_JID.matcher(jid); + return matcher.find(); + } +} -- cgit v1.2.3