Trying to think of
what to do next I thought how it would be nice to invoke another language, such
as Ada, from C# to avoid the C# problems of not being able to call the action
routines of the pseudo OFP from a table.
Such as a table with callback addresses as can be done from Ada.
Then it occurred to
me that I should be able to do it already.
With a post of a little while back – C# Displays
Follow On for ExpenseIt Using the Framework – I was able to use the C# message delivery
framework along with Windows Forms.
Therefore, instead of sending messages to a database component of the
same application as done in that post, I could send messages from the C# visual
compiler application to a remote Ada application where the Ada application
could mimic the very highly reduced OFP.
And the actions could then be invoked from a table of callbacks.
Problem solved. Except, of course, it can only be done
between C# and Ada unless the message delivery framework is extended into yet
another language.
However, then I
thought how the years ago message delivery framework of the EP and IP
experimental projects is much too complicated.
One reason being that they were test beds for trying out new interfaces
and methods of sending and receiving message topics. So those versions of the framework, as written in Ada, have much
that is superfluous to what the much simpler C# framework does. So I used this as an excuse to start
creating a simpler Ada version that will result in an Ada message delivery
framework that mimics the C# version.
As I have now
started, I can see that this will definitely keep me busy for some time so I
won't need to worry about having something to do. For instance, in following the C# code, I found that I needed
some Ada procedures and functions to implement C# system functions. This post will provide those that I came across
in just two of the C# methods and present the corresponding C# code with the
new Ada equivalent.
The C# system methods
are the string append represented by
String1 += String2;
and, from my C#
Format class DecodeHeartbeatMessage method
int j
= String.Compare(subString1, "Heartbeat", false);
i =
subString1.IndexOf('|');
subString2
= subString1.Substring(i + 1, subString1.Length - i - 1);
bool
result = Int32.TryParse(numeric, out field);
So I
implemented these methods to start with.
As I proceeded implementing the pair of C# methods
(EncodeHeartbeatMessage as the second method) I found that I didn't need the
append routine but did need additional C# method replacements.
As will
be seen, the usage of the Ada versions will be more difficult than the C#.
Part
(most?) of the reason is that Ada requires that the size of the strings be
specified when they are declared.
Therefore, since I didn't want the implementation of these methods to
need to declare large strings in order to be sure to have enough space for a
wide variety of strings to be passed to the Ada functions/procedures I supplied
the address of the string to the Ada routine so its size could be whatever was
needed.
These
C# methods are implemented in a new CStrings package.
CStrings
Ada package
The Ada
CStrings package specification is as follows.
with
System;
package
CStrings is
subtype CompareResultType is Integer range
-1..+1;
subtype IntegerSizeType is Natural range
0..64;
subtype StringOffsetType is Integer;
subtype StringType is System.Address;
-- Provides location of a string / character
array
type SubStringType is
record
Length : Natural;
-- Number of characters in the substring
Value
: System.Address;
-- Location of the string / character
array
end record;
function AddToAddress
( Location : in System.Address;
Amount
: in Integer
) return System.Address;
procedure Append
( First
: in StringType;
-- First string to append Second to
Second : in StringType;
-- String to append to First
Result : in out SubStringType
-- Available length upon input;
concatenated string with length upon output
);
-- Append strings; C# +=
function Compare
( Left
: in StringType;
-- First substring to be compared
Right
: in StringType;
-- Other substring to be compared with
first
IgnoreCase : in Boolean
-- True if case is to be considered
) return CompareResultType;
-- Return whether Left is alpha sort before
Right, equal, or after
-- The compare can go until the trailing nul
characters
function IndexOf
( From : in StringType;
-- String to be searched
Find : in StringType
-- String to search for
) return StringOffsetType;
-- Return location in From at which Find was
located
function IndexOf1
( From : in StringType;
-- String to be searched
Find : in Character
-- Character to search for
) return StringOffsetType;
-- Return location in From at which Find was
located
procedure IntegerToString
( From
: in Integer;
-- Integer to convert to a string
Size
: in IntegerSizeType;
-- Number of characters into which the
converted value must fit
Result
: out String;
-- Converted value with trailing NUL
Success : out Boolean
-- Whether From fits in Size (plus 1 for
the NUL)
);
-- Convert Integer to string that will fit
in string of Size
function Substring
( From
: in StringType;
-- String from which to extract a
substring
Start : in StringOffsetType;
-- Start index into From
Stop
: in StringOffsetType
-- End index into From
) return String;
-- Extract substring from From and return
from Start to Stop - 1
procedure TryParse
( From
: in StringType;
-- String from which to extract a
substring and convert
Size
: in IntegerSizeType;
-- Number of bits into which the converted
value must fit
Result
: out Integer;
-- Converted value as normal sized integer
Success : out Boolean
-- Whether From string is numeric and fits
in Size
);
-- Convert String to integer that will fit
in one of Size
end
CStrings;
I didn't finish the
implementation of IndexOf since I was only looking for single characters. Therefore, IndexOf1 is the function that is
used.
In most every
instance the implementation uses the trailing NUL character of C and C# as the
string terminator. This is because the
Ada message framework will be receiving messages from C# where the message data
will have a terminating NUL. Therefore,
the CStrings methods will expect a terminating NUL except for IndexOf where the
input string can be much longer.
I could have chosen
to use an array of bytes as I did before to create the data for a message and
then convert to a string when the message is published. But I chose to follow the C# code and create
a message data string.
Thus, the Ada body of
CStrings is
with
Interfaces.C;
with
System.Storage_Elements;
with
Text_IO;
package
body CStrings is
package C renames Interfaces.C;
Adjustment
: constant := Character'Pos('a') -
Character'Pos('A');
function AddToAddress
( Location : in System.Address;
Amount
: in Integer
) return System.Address is
use System.Storage_Elements;
begin -- AddToAddress
return
Location+System.Storage_Elements.Storage_Offset(Amount);
end AddToAddress;
procedure Append
( First
: in StringType;
Second : in StringType;
Result : in out SubStringType
) is
Available : Integer := Result.Length;
Last : Integer := 0;
FirstAddr
: System.Address := First;
SecondAddr : System.Address := Second;
OutAddr
: System.Address := Result.Value;
begin -- Append
-- Copy First string to Result
while (True) loop
declare
Location : System.Address :=
FirstAddr;
OutLoc : System.Address := OutAddr;
begin
declare
FirstChar : Character;
for FirstChar use at Location;
OutChar : Character;
for OutChar use at OutLoc;
begin
if FirstChar = ASCII.NUL then -- end
of first string
exit; -- loop; don't copy the
trailing NUL
else
if Last < Available then
Last := Last + 1; -- copy character into
OutChar := FirstChar; -- output string
else
Text_IO.Put_Line("First
string too long for output");
raise Constraint_Error;
end if;
end if;
end;
FirstAddr :=
AddToAddress(FirstAddr,1);
OutAddr := AddToAddress(OutAddr,1);
end;
end loop;
-- Copy Second string to Result
while (True) loop
declare
Location : System.Address :=
SecondAddr;
OutLoc : System.Address := OutAddr;
begin
declare
SecondChar : Character;
for SecondChar use at Location;
OutChar : Character;
for OutChar use at OutLoc;
begin
if Last < Available then
Last := Last + 1; -- copy character to
OutChar := SecondChar; -- output Result string
if SecondChar = ASCII.NUL then --
end of second string
Result.Length := Last; -- Set
output Result Length
exit; -- loop to return with new
string
end if;
else
Text_IO.Put_Line("Second
string too long for output");
raise Constraint_Error;
end if;
end;
SecondAddr :=
AddToAddress(SecondAddr,1);
OutAddr := AddToAddress(OutAddr,1);
end;
end loop;
end Append;
function ToLower
( Char : in Character
) return Character is
begin -- ToLower
if Char in 'A'..'Z' then
return Character'Val(
Character'Pos(Char) + Adjustment );
else
return Char;
end if;
end ToLower;
function Compare
( Left
: in StringType;
Right
: in StringType;
IgnoreCase : in Boolean
) return CompareResultType is
Index : Integer := 0;
LeftAddr
: System.Address := Left;
RightAddr : System.Address := Right;
begin -- Compare
while (True) loop
Index := Index + 1;
declare
LeftLoc : System.Address := LeftAddr;
RightLoc : System.Address :=
RightAddr;
begin
declare
LeftChar : Character;
for LeftChar use at LeftLoc;
RightChar : Character;
for RightChar use at RightLoc;
begin
if IgnoreCase then
LeftChar := ToLower(LeftChar);
RightChar := ToLower(RightChar);
end if;
if LeftChar = ASCII.NUL and then
RightChar = ASCII.NUL then
return 0; -- match
elsif LeftChar = ASCII.NUL then
return 1; -- treating blank fill
etc as mismatch
elsif RightChar = ASCII.NUL then
return -1; -- treating blank fill
etc as mismatch
elsif LeftChar < RightChar then
return 1; -- right string greater
elsif LeftChar > RightChar then
return -1; -- left string greater
end if;
end;
LeftAddr := AddToAddress(LeftAddr,1);
RightAddr :=
AddToAddress(RightAddr,1);
end;
end loop;
return -1;
end Compare;
function IndexOf
( From : in StringType;
Find : in StringType
) return StringOffsetType is
Done
: Boolean;
Index : Integer := 1;
FromAddr : System.Address := From;
FindAddr : System.Address := Find;
TempFind : array(0..120) of Character;
begin -- IndexOf
-- Obtain array of characters to search
for in From. Limit the number
-- of characters in Find.
Done := False;
declare
Location : System.Address := FindAddr;
begin
declare
FindChar : Character;
for FindChar use at Location;
begin
if FindChar /= ASCII.NUL then
TempFind(Index) := FindChar;
Index := Index + 1;
else
Done := True;
end if;
end;
if not Done and then Index < 120 then
Location := AddToAddress(Location,1);
end if;
end;
–- Returning 0 for not found since this
function isn't implemented
return 0;
end IndexOf;
function IndexOf1
( From : in StringType;
Find : in Character
) return StringOffsetType is
Found : Boolean := False;
Index : StringOffsetType := 1;
FromAddr : System.Address := From;
begin -- IndexOf1
while (not Found) loop
declare
Location : System.Address := FromAddr;
begin
declare
FromChar : Character;
for FromChar use at Location;
begin
if FromChar = Find then
Found := True;
return Index;
elsif FromChar = ASCII.NUL then --
at end of C string
Found := True;
return 0;
end if;
end;
end;
Index := Index + 1;
FromAddr := AddToAddress(FromAddr,1);
end loop;
return 0;
end
IndexOf1;
procedure IntegerToString
( From
: in Integer;
Size
: in IntegerSizeType;
Result
: out String;
Success : out Boolean
) is
ResultData : String(1..Size+1); --
sufficient to contain the converted integer
Index : Integer := 0; -- location at which
to insert '-' sign
Sign
: Character := ' ';
TempFrom : Integer;
-- From as positive
begin -- IntegerToString
if From < 0 then
Sign := '-';
end if;
TempFrom := Abs(From);
ResultData := ( others => '0' );
for I in reverse 1..Size loop
ResultData(I) := Character'Val(
Character'Pos('0') + ( TempFrom mod 10) );
TempFrom := TempFrom / 10;
exit when TempFrom = 0;
end loop;
if TempFrom /= 0 then -- From cannot be
converted into Size characters
Success := False;
Result := (others => ASCII.NUL);
return;
end if;
Index := 0;
for I in 1..Size-1 loop -- replace leading
0s with spaces
exit when ResultData(I) /= '0';
Index := I; -- index of last space
ResultData(Index) := ' ';
end loop;
if Sign /= ' ' then -- negative sign
if Index > 0 then -- available
position for negative sign
ResultData(Index) := Sign;
else -- From cannot be converted into
Size characters
Success := False;
Result := (others => ASCII.NUL);
return;
end if;
end if;
ResultData(Size+1) := ASCII.NUL; -- append
trailing NUL
Success := True;
Result := ResultData;
end IntegerToString;
function Substring
( From
: in StringType;
Start : in StringOffsetType;
Stop
: in StringOffsetType
) return String is
-- Create C char_array at location of From
Size : C.size_t := C.size_t(Stop-Start+2);
FromData : C.char_array(1..Size);
for FromData use at From;
Index : Integer;
-- Ada string to be returned
Temp : String(1..Stop-Start+2);
begin -- Substring
-- Copy substring characters from location
at From to Ada string.
Index := 0;
for I in Start..Stop loop
Index := Index + 1;
Temp(Index) :=
Character(FromData(C.size_t(I)));
if Temp(Index) = ASCII.NUL then -- quit
early if trailing NUL found
exit; -- loop
end if;
end loop;
-- Insert trailing NUL if needed
if Temp(Index) /= ASCII.NUL then
Index := Index + 1;
Temp(Index) := ASCII.NUL;
end if;
-- Return NUL terminated Ada string.
return Temp(1..Index);
end Substring;
procedure TryParse
( From
: in StringType;
Size
: in IntegerSizeType;
Result
: out Integer;
Success : out Boolean
) is
Digit : Integer;
Sign
: Integer := 1;
Start : Integer := 1;
Number : Integer := 0;
FromData : String(1..Size);
for FromData use at From;
begin -- TryParse
Result := 0;
Success := False;
-- Check for leading spaces
for I in 1..Size loop
if FromData(I) /= ' ' then
Start := I;
exit;
end if;
end loop;
-- Check for a leading non-space sign
if FromData(Start) = '+' then
Start := Start + 1; -- bypass sign
elsif FromData(Start) = '-' then
Start := Start + 1;
Sign
:= -1; -- to make number negative
end if;
-- Check if rest of From array contains
numeric digits and convert
for I in Start..Size loop
if FromData(I) not in '0'..'9' then
if I = Size and then FromData(I) =
ASCII.NUL then
null; -- ignore trailing NUL
else
return; -- with failure
end if;
else -- valid digit
Digit := Character'Pos(FromData(I)) -
Character'Pos('0');
Number := Number * 10 + Digit;
end if;
end loop;
Result := Sign*Number; -- take any leading
sign into account
Success := True;
end TryParse;
end
CStrings;
These routines take
advantage of Ada's ability to overlay a variable with one of a different format
(but the same size) by setting its address to that of the other variable. For instance, in a declare block
declare
SecondChar : Character;
for SecondChar use at Location;
OutChar : Character;
for OutChar use at OutLoc;
begin
Also, they use the
AddToAddress function declared at the beginning to be able to increment through
the strings. For instance,
SecondAddr :=
AddToAddress(SecondAddr,1);
OutAddr := AddToAddress(OutAddr,1);
where the
AddToAddress function allows the value to be added to be other integers besides
the use of 1 illustrated. This is made
use of in the Format DecodeHeartbeatMessage method.
The Append function
copies the First string to the Result Value string except for its terminating
NUL and then copies the Second string to follow it in Result Value. The available size allocated for the
concatenated string is input in the Result Length and then replaced for output
by the length of the Result Value string.
Compare assumes NUL
terminated strings and returns 0 if the strings are the same length and
match. If the strings are of different
lengths than this implementation ignores whether the extra characters are
spaces and returns either plus or minus 1 to indicate a mismatch.
IndexOf1 scans for
the supplied Find character and returns its location in the From string or 0 if
the Find character can't be found.
IntegerToString
converts the supplied From integer to a string and then determines if leading
string positions can be ignored to return a string of the specified Size where
the Size assumes that the invoking routine has left space for the terminating NUL.
Substring returns the
characters between Start and Stop in the From string or those up to the
terminating NUL. The returned string
will have a terminating NUL.
IntegerToString and
TryParse assume the Integer conversion version of the C# method. That is, unlike C#, the type of the
conversion cannot be supplied. Well, in
the case of IntegerToString the conversion from an Integer is not assumed since
a generalized C# ToString isn't used.
C# Format
DecodeHeartbeatMessage method
In the C# version of
the message framework there is a Format class as provided in Format.cs. This class contains methods to decode and
encode a few messages and can be extended as desirable. One of these methods is to decode a
"heartbeat" message that is transmitted from a remote application
(that is, not the application that is currently running) to the currently
running application to detect if the connection between the two application has
become broken by not receiving the message in a timely manner or receiving a
garbaged version of the message.
The C# code for
decoding the message is as follows.
static public bool DecodeHeartbeatMessage
(Delivery.MessageType message, int
remoteAppId)
{
if ((message.header.id.topic ==
Topic.Id.HEARTBEAT) &&
(message.header.id.ext == Topic.Extender.FRAMEWORK))
{
// assuming rest of header is ok
if (message.header.size != 15)
{
Console.WriteLine("Heartbeat message has a size other than 15
{0}",
message.header.size);
return false;
}
string numeric = "";
string subString1 = "";
string subString2 = "";
// Find first delimiter, if any.
int i = message.data.IndexOf('|');
int l = 0;
if (i > 0)
{ // Is substring prior to
delimiter the message id?
subString1 =
message.data.Substring(0, i);
int j =
String.Compare(subString1, "Heartbeat", false);
if (j == 0)
{ // Yes - Heartbeat message
l = message.data.Length -
i - 1;
subString1 =
message.data.Substring(i + 1, l);
i = subString1.IndexOf('|');
numeric =
subString1.Substring(0, i);
subString2 =
subString1.Substring(
i + 1,
subString1.Length - i - 1);
int field;
bool result = Int32.TryParse(numeric,
out field);
if (result)
{
if (field ==
remoteAppId)
{ // 1st field is as
expected
i =
subString2.IndexOf('|');
if (i > 0)
{
subString1 =
subString2.Substring(
i + 1, subString2.Length - i - 1);
numeric =
subString2.Substring(0, i);
result =
Int32.TryParse(numeric, out field);
if (result)
{
if (field
== App.applicationId)
{ // 2nd field is as expected;
finished checking
// consecutiveValid++;
return
true; //heartbeatMessage = true;
}
}
}
}
}
}
}
}
return false;
} // end DecodeHeartbeatMessage
Note: When doing the Ada version I noted that the
last field is not extracted and converted to numeric.
Ada Format
DecodeHeartbeatMessage function
The Ada version of
this same routine required the use of the CStrings functions (except for Append
which the Encode routine will need). Even with the use of the CStrings package, the code is not as
straight forward as can be seen below.
function DecodeHeartbeatMessage
( Message : in Delivery.MessageType;
RemoteAppId : in Integer
) return Boolean is
I : CStrings.StringOffsetType;
-- Index into string
J : CStrings.CompareResultType;
-- 0 if two strings compare
L : CStrings.StringOffsetType;
-- Length of substring
Msg
: CStrings.StringType :=
Message.Data'Address;
use type Topic.Extender_Type;
use type Topic.Id_Type;
begin -- DecodeHeartbeatMessage
if Message.Header.Id.Topic /=
Topic.HEARTBEAT) or else
Message.Header.Id.Ext /=
Topic.FRAMEWORK
then
return False;
end if;
-- assuming rest of header is ok
if
Message.Header.Size /= 15 then
Text_IO.Put("Heartbeat message has
a size other than 15 ");
return False;
end if;
-- Find first delimiter, if any.
I := CStrings.IndexOf1(Msg, '|');
L := 0;
if I > 0 then -- delimiter found
-- Is substring prior to delimiter the
message id?
declare
SubString1
-- String where NUL will be located at
position corresponding to '|'
: String(1..I);
Heartbeat : String(1..10);
begin
SubString1 := CStrings.Substring(Msg,
1, I-1); -- string prior to '|'
Heartbeat(1..9) :=
"Heartbeat";
Heartbeat(10) := ASCII.NUL;
J :=
CStrings.Compare(Substring1'Address, Heartbeat'Address, False);
if J /= 0 then
-- miscompare
return False; -- not Heartbeat
message
end if;
end;
else
return False; -- not Heartbeat message
end if;
-- Heartbeat message
L := Message.Header.Size - I + 1; -- where
I is location of the '|'
declare
SubString1 : String(1..L);
-- String where NUL will be located at
position corresponding to '|'
Msg1 : CStrings.StringType :=
CStrings.AddToAddress(Msg,I);
begin
SubString1 := CStrings.Substring(Msg1,
1, L-1);
I := CStrings.IndexOf1(SubString1'Address,'|');
declare
Numeric : String(1..I);
SubString2 :
String(1..SubString1'Length-I);--width with up to | removed
Field
: Integer;
Result : Boolean;
Msg2 : CStrings.StringType :=
CStrings.AddToAddress(Msg1,I);
begin
Numeric :=
CStrings.Substring(SubString1'Address, 1, I-1);
L := SubString1'Length - I -1;
CStrings.TryParse(Numeric'Address,
Numeric'Length, Field, Result);
if not Result then
return False;
end if;
if Field /= remoteAppId then -- 1st
field not as expected
return False;
end if;
-- Get "to" app id
SubString2 := CStrings.SubString(Msg2,
1, L+1);
I := CStrings.IndexOf1(SubString2'Address,'|');
Numeric :=
CStrings.Substring(SubString2'Address, 1, I-1);
L := SubString1'Length - I -1;
CStrings.TryParse(Numeric'Address,
Numeric'Length, Field, Result);
if not Result then
return False;
end if;
if Field /= Itf.ApplicationId.Id then
-- 2nd field not as expected
return False;
end if;
-- Get Heartbeat Iteration. Otherwise ignore it for now.
declare
SubString3 : String(1..SubString2'Length-I);--width
with up to | removed
begin
SubString3 :=
CStrings.SubString(Msg3, 1, L+1);
end;
end;
end;
return True;
end DecodeHeartbeatMessage;
In this
implementation declare blocks are used to declare the strings of the correct
sizes depending upon where the delimiter (that is, the | character) was
found. And the Data Message, first
declared as Msg and located at the address of the Message.Data portion of the
Message, moved across the message data via declarations such as
Msg1 : CStrings.StringType :=
CStrings.AddToAddress(Msg,I);
and
Msg2 : CStrings.StringType :=
CStrings.AddToAddress(Msg1,I);
and
Msg3 : CStrings.StringType :=
CStrings.AddToAddress(Msg2,I);
where Msg1, Msg2, and
Msg3 are addresses since they are of CStrings.String_Type and AddToAddress is
used to move the location of the message data to be used to the right.
C# Format
EncodeHeartbeatMessage method
static public Delivery.MessageType EncodeHeartbeatMessage(int appId)
{
string msg = "Heartbeat|" + App.applicationId + "|"
+
appId + "|" + heartbeatIteration;
Delivery.MessageType message;
message.header.CRC = 0;
message.header.id.topic = Topic.Id.HEARTBEAT;
message.header.id.ext = Topic.Extender.FRAMEWORK;
Component.ParticipantKey key;
key.appId = App.applicationId;
key.comId = 0; // for Framework
key.subId = 0;
message.header.from = key;
key.appId = appId;
message.header.to = key;
message.header.referenceNumber = 0;
message.header.size = (short)msg.Length;
message.data = msg;
return message;
}
// end EncodeHeartbeatMessage
Note that the msg
string at the top just concatenates the portions of the message data together
with the | delimiters. It is then set
to message.data towards the end.
Ada Format
EncodeHeartbeatMessage function
function EncodeHeartbeatMessage
( RemoteAppId : in Integer
) return MessageType is
Success : Boolean;
Id
: String(1..2);
Message : MessageType; -- message to be
returned
Msg : String(1..25) := (others =>
ASCII.NUL); -- enough space for Data
for Msg use at Message.Data'address;
begin -- EncodeHeartbeatMessage
Msg(1..10) := "Heartbeat|";
CStrings.IntegerToString(
Itf.ApplicationId.Id, 1, Id, Success );
Msg(11) := Id(1);
Msg(12..12) := "|";
CStrings.IntegerToString( RemoteAppId, 1,
Id, Success );
Msg(13) := Id(1);
Msg(14..14) := "|";
CStrings.IntegerToString(
HeartbeatIteration, 1, Id, Success );
Msg(15) := Id(1);
Message.Header.CRC := 0;
Message.Header.Id.Topic := Topic.HEARTBEAT;
Message.Header.Id.Ext := Topic.FRAMEWORK;
Message.Header.From(1) :=
Machine.Unsigned_Byte(Itf.ApplicationId.Id);
Message.Header.From(2) := 0;
Message.Header.From(3) := 0;
Message.Header.To(1) :=
Machine.Unsigned_Byte(RemoteAppId);
Message.Header.To(2) := 0;
Message.Header.To(3) := 0;
Message.Header.ReferenceNumber := 0;
Message.Header.Size := 15;
return Message;
end EncodeHeartbeatMessage;
Note that this Ada
version doesn't need the CStrings Append procedure. The Data string (as overlaid with Msg) just builds up Msg which
will have a terminating NUL. Thus the
Ada version of the routine is not really any more complicated than the C#
version except for needing the IntegerToString procedure to convert the three integers
to a string before copying into Msg.
The function depends
upon the three integers never exceeding a single digit (that is, 0 to 9) so
that IntegerToString would return a conversion failure. In the interface application being created
between C# and Ada the two Application Ids will only be 1 and 2.
No comments:
Post a Comment