XE5 - Fun with external Java libraries

XE5 - Fun with external Java libraries

So, having been beta testing and now with a live XE5 one of the things I have been tasked to investigate is the modularity available to Android apps - specifically using code written in Eclipse/Android studio in a XE5 Android application.

Now, I have to assume a few things here - you are reasonably familiar with delphi AND you can create a library APK yourself.  It’s not hard, honest.  You DO need to be able to create an APK or JAR with a classes.dex AND I am only (for my purposes) interested in talking to a java class which I have code for - its possible to do this without the actual java code but you will need the classes.dex file. You also need to be brave to delve into the world of JNI.

Android API
To do any of this you have to hit the Android API support functions that are provided, specifically you need;

• Androidapi.Jni
• Androidapi.JNI.JavaTypes
• Androidapi.JNI.Dalvik
• Androidapi.JNIBridge
• Androidapi.JNI.GraphicsContentViewText
• FMX.Helpers.Android

It took me a bit of grepping and source spelunking to work this lot out, I would advise you do the same – it is (interestingly) informative.

Loading a class dynamically
Java provides a set of mechanisms to allow you split code across JAR files, these involve classloaders and generally add some reflection to interact with the contained classes.

Android extends this to provide support for compiled and compressed java classes contained in DEX files - DEXClassLoader. This android class will allow you to load and get class references of a contained class – which you can then use to create objects and interact with.

I found a Delphi import for DEXClassLoader in Androidapi.Jni.Dalvik – add this to your project and you can use it.  But hold on a moment – go read the Android developer page on the DexClassLoader and you will see that there are a couple of things we need to do first.

1. We need a full path to the APK/JAR file that is correct for the device you are targeting.
2. We need an application writable folder to put the optimized DEX file in (don’t ask).
3. We need a “parent” for the class loader.

These things stumped me for a while – but help is at hand, well a helper class anyway.  Delphi helpfully supplies a FMX.Helpers.Android unit – this has a few useful things in it, but most importantly it provides access to the Android level context and native activity objects.  If you don’t understand what these are then I would suggest a bit of research and learning about android before you get in over your head.

Setting up
Presuming you have created a java class project using eclipse or android studio AND presuming that it compiles then you need to copy that to your device.  Logically it should be done as part of the application deployment, but at the time of writing I hadn’t figured that part out (or the deploying of extra files is broken somehow, now that XE5 is released I must try this part again) – I put mine into the Downloads folder of the internal (non-system) disk of my Nexus 7 simply by dragging and dropping from windows. 
Using a handy SManager app I had already installed (which contains a workable file manager) I managed to get a file path for my APK that was (ready for this?)

“/storage/emulated/0/Download/

There is almost certainly a way of getting this folder at run time using the Android API – I’ll leave you to figure that out.  Unless you are also targeting a Nexus 7 this path is probably going to be useless to you – put your APK into a location that makes sense to you and using a file manager app on the device to get the one that’s correct for you.  OR go work out how to get the external storage from the API – I’ll give you a hint in a mo!
If you have a rooted device and you can get access to the deployment folder in /data/data then you can deploy the APK into the libs folder (as you are supposed to); however I found that first of you have to root your device to do it which is probably not a good idea unless you are targeting rooted devices specifically and secondly that you will have to do it each time you hit the run or debug button in Delphi.  I quickly got bored of that game and placed my APK elsewhere. I will have to test actually installing the library APK and see where it goes and if you can load it still in the same way.  Something for another day perhaps.

Get the directories
FMX.Helpers.Android contains a global var “SharedActivityContext” – we can use this to get some bits of information back about the local Android environment – notably the “getDir” function to query where you can put stuff – this in really useful in android apps – you can use this to work out where to store state information for your app, or where to put databases you are using. The Android Dev help page for this is here.
You’ll need to use the helper function STRINGTOJSTRING to convert any Delphi strings to JSTRINGS to pass them to a java call – including the GETDIR function.

Code sample
const
  test_apk_fn='/storage/emulated/0/Download/test.apk';
var
  context:JContext;
  dexpath_jfile,optimizedpath_jfile:JFile;
  dexpath_jstring,optimizedpath_jstring:JString;
begin
  context:=SharedActivityContext;

  dexpath_jstring:=StringToJString(test_apk_fn);
  optimizedpath_jfile:=context.getDir(StringToJString('outdex'),
     TJContext.javaclass.mode_private);
  optimizedpath_jstring:=optimizedpath_jfile.getAbsolutePath;

Note that we have to jump through a couple of hoops – notably getDir returns a JFILE type which we call the GETABSOLUTEPATH member of to return something useful , also used the TJContext.JavaClass to access the MODE_PRIVATE constant – this is true of all the imported android java classes, the .javaclass member provides convenient access to class level constants and class procedures and functions that are not immediately visible in delphi.

Load the library, then load the class
Next step – the title says it all really, we are going to load the class.  The important part here is the call to the correct constructor – calling the TDelphiClass.create constructor never worked for me, I had to find the constructor (typically called INIT) and hit it directly.  This allows you to specify where the APK/JAR is and where to optimise the classes to – this second folder has to be application writeable so it is suggested that you make use of the context.getDir function to ensure that you have full access at run time.
Using the above code example, the call to create the class loader looks like this:-

cl:=TJDexClassLoader.JavaClass.init(dexpath_jstring,optimizedpath_jstring,nil,TJDexClassLoader.JavaClass.getSystemClassLoader);

Where cl is a JDexClassLoader variable – not a TJDexClassLoader. IF this works you get a JDexClassLoader back – if the result is NIL then you have gotten something wrong with your paths or the APK/JAR library. 

This done, you make a call to the loadclass function – this (if it works) will return a JLang_Class object which represents the class in java.  For my test I had a simple, parameter-less constructor, if you have to pass parameters to the constructor then you will need to jump through more hoops I’m afraid – you’ll have to hit the JNI to find the correct constructor (using the method signature) – I’ll be covering this in a mo.

For my testing I could use a simple call to newInstance.

Code sample
const
  test_apk_fn='/storage/emulated/0/Download/test.apk';
var
  context:JContext;
  dexpath_jfile,optimizedpath_jfile:JFile;
  dexpath_jstring,optimizedpath_jstring:JString;
  cl:JDexClassLoader;
  jLoadedClass:Jlang_Class;
  jLoadedObject:JObject;
begin
  context:=SharedActivityContext;

  dexpath_jstring:=StringToJString(test_apk_fn);
  optimizedpath_jfile:=context.getDir(StringToJString('outdex'),
     TJContext.javaclass.mode_private);
  optimizedpath_jstring:=optimizedpath_jfile.getAbsolutePath;

  cl:=TJDexClassLoader.JavaClass.init(dexpath_jstring, optimizedpath_jstring,nil, TJDexClassLoader.JavaClass.getSystemClassLoader);
  
  {you should test for a nil return here and implement error handling}

  jLoadedClass:=cl.loadclass(
    stringtoJString(‘com/example/test/testclass’));
  jLoadedObject=jLoadedClass.newInstance;
end;

Note the class path, you are not using the expected package naming com.domain.package that you would use in Java – if you take the time to examine some of the RTL Android imports you will see that the java signatures take the com/domain/package/classname format – you can list the classes and the class methods defined in a APK/JAR by extracting the classes.dex file and running DEXDUMP on it (which is included in the Android SDK, helpfully enough).

Finally, also note that class, package and indeed everything is Java is case sensitive.

_Using JNI to find and call methods _
When using java classes that are defined by the RTL, JNI is taken care of behind the scenes, the classes and class methods are mapped to interfaces according to name and signature.  This process is seemless and very easy to use should you find an Android class which is not already imported for you by the RTL.
There are a couple of things you need to know before you start, however.  Firstly you need to know the exact case of the java method you are calling, its best to either have the source code to check OR to run DEXDUMP on the classes.dex file.  

You gain access to the JNI base calls by using the PJNIENV structure from Android.JNI.  You get a reference to one valid to the current run time environment by using the JNIRESOLVER class in Android.JNIBridge.

Code sample

Uses
  Android.JNIBridge;
var
  JavaEnv:PJNIEnv;
Begin
  JavaEnv:=TJNIResolver.GetJNIEnv;
End;

This done, you can now make base JNI calls using this structure – rather than being a class, PJBIEnv is in fact a record.
There is a final bit of trickery needed to use JNI – you need to get JNIObject references for the loaded class and the java object to pass the JNI methods.  Fortunately, you can get to these objects by using interfaces.  

Code sample

var
  jLoadedObject:JObject;
  jLoadedClass:JLang_Class;
  jLoadedClassID,jLoadedObjectID:JNIObject;
begin
  {...do the class load and get the references to the java class and java object first!}

  jLoadedClassID:=(jLoadedClass as ILocalObject).GetObjectID;
  jLoadedObjectID:= (jLoadedObject as ILocalObject).GetObjectID;
end;
 
ALL java imports implement the ILocalObject interface – however you can wrap this in error checking and trapping easily enough by doing a QueryInterface on the java objects.  Note that the java class returned by loadclass is, in fact, just another object to the JNI.

Finally, you have everything you need to query and call members of the java classes you import – the simplest way of querying a java class is to use the TJNIResolver class (although you can do it with direct JNI calls using the PJNIEnv structure).

For my internal testing I implemented two simple methods in my java class – on to set a internal integer value and one to return it.  This is designed to be a proof of concept and nothing more, as a result I have not attempted to cover calling java methods that take multiple parameters, although I don’t think its much more difficult than this.

Code sample
var
  jLoadedClassID,jLoadedObjectID:JNIObject;
  jGetMethod,jSetMethod:JNIMethodID;
begin
  {...don’t forget the other bits first!}

  jGetMethod:=TJNIResolver.GetJavaMethodID(jLoadedClassID ,'getIntValue','()I');
  jSetMethod:=TJNIResolver.GetJavaMethodID(jLoadedClassID ,'setIntValue','(I)V');
end;

Note that you need to know the method signature before you call it – doing a DEXDUMP on classes.dex will tell you this – although its not hard to translate from the source.  My test class had two methods – function “getIntValue” returning a 32bit integer and procedure “setIntValue” taking 1 integer parameter.  This gives a method signature of “()I” and “(I)V”, respectively.
Calling these methods requires you to use two JNI CallXXXXMethod members, there are many defined for passing parameters and returning results in three ways. I am using the A methods which take the arguments as a JNIValue (well, a pointer to one or a pointer to an array of them).

Code sample
var
  jLoadedClassID,jLoadedObjectID:JNIObject;
  jGetMethod,jSetMethod:JNIMethodID;
  JavaEnv:PJNIEnv;
  jiReturnedValue:JNIInt;
  jvValuetoSet:JNIValue;
begin
  {...don’t forget the other bits first!}

  jiReturnedValue:=JavaEnv^.CallIntMethodA(JavaEnv, jLoadedObjectID,jGetMethod);

  jvValuetoSet.i:=1234567890;
  JavaEnv^.CallVoidMethodA(JavaEnv, jLoadedObjectID, jSetMethod,@jvValuetoSet);
end;

There you have it – easy when you know how, eh?

Comments

  1. Any idea why is the JNI interface set as android only.
    such an interface could also be usefull for other platforms. eg windows , Mac osX , future Linux ?

    ReplyDelete
  2. Dunno - I suspect that Linux support will follow with the release of Android support, so this could change in the next release.  I am explicitly targetting android here so I didn't bother to investigate if or how this implemented for other platforms.

    ReplyDelete
  3. Good work on writing it up before anyone else :o)
    Extra points if you can now incorporate the Java Bridge interface front-end to massively simplify the process.

    ReplyDelete
  4. Maybe another time :-)  In this case I was specifically working on how to load the library and interact with it without any design time knowledge of it outside of the class and method signatures.

    ReplyDelete
  5. Damnit.  Now you've peaked my curiosity :-) Got to bail to do the school run, do you have a hint Brian Long - WrapJNIReturn perhaps?

    ReplyDelete
  6. "In this case I was specifically working on how to load the library and interact with it without any design time knowledge of it outside of the class and method signatures." -> what I'm thinking of (and what I implemented) fits into that template, and works nigh on exactly the same as the regular Android Java Bridge imports.
    I'll be sure to cover it in my CodeRage 8 talk :o)

    ReplyDelete
  7. Wow.  That's a hell of a lot of very harum scarum work and lots of ugly code, just to be able to call two very simple methods.  ;)

    In Oxygene you would just write:

      i := obj.IntValue;

    and/or

      obj.IntValue := i;

    Oxygene recognises the get/set property pattern and calls the appropriate methods, or you can still call them explicitly if you prefer:

      i := obj.getIntValue;

    ReplyDelete
  8. I agree joylon they could add syntactic sugar , but once the classes are generated the interface works as you specify.
    I remember in the old days there was a program that was generating delphiJNI interfaces for java classes and I thought they were using something similar. Still why not publish the way those units were generated, because I don't believe they were hand coded.

    ReplyDelete
  9. Jolyon Smith I agree, its ugly. But Oxygene is a different beast entirely directly targeting Java - here we are in native arm and this is simply a first pass at the problem of talking to Java classes not part of the defined api. I'm sure Brian Long has a much more elegant solution to hand :-)

    Are you going to use xe5 Jolyon Smith or stick with oxygene? Myself I do wonder if smart mobile studio would be worth a look too.

    ReplyDelete
  10. The sheer fun and enjoyment of working with Android and Cocoa using Oxygene very much reminds me of the same eye-opening experience that Delphi 1 was on Windows, back in the day.

    But l will be sticking with Delphi for VCL Win32 development.  Embarcadero may have abandoned it but it's still the best option for that platform imho.  Whether I bother moving up to XE5 though, I doubt.

    But for mobile development, WIndows 8/RT and OS X Delphi has simply gone in entirely the wrong direction imho.

    ReplyDelete
  11. Really? I must admit I was hoping for Java output, rather than native arm, but targeting android is the right thing - nothing is going to stop Google dominating mobile space now.

    Is oxygene much different from prism? I have used vs c# and mono, plus now some c++ and Java but nothing compares to Delphi for me. I do wonder if I ought to revisit it...

    ReplyDelete
  12. Oxygene is Prism. Or more accurately Oxygene for .NET is Prism. And Oxygene for Java and for Cocoa use the same language, same dev env, to target Java or Dalvik or iOS.
    Some real nice enhancements to the language. And if you;re keen on targeting a single platform, it makes for a real nice Pascal programming experience (once you tweak VS's reg settings to stop it shouting all caps menus at you)

    ReplyDelete
  13. Re my approach. Here's my Java class:

    public class Foo
    {
        public int Bar(final Activity activity)
        {
             activity.runOnUiThread(new Runnable()
             {
                   @Override
                    public void run()
                    {
                        Toast.makeText(activity.getApplicationContext(), "Hello world from a Java .jar library", Toast.LENGTH_SHORT).show();
                    }
             });
            
            return 57;
        }
    }

    Here's my Delphi import unit:

    unit FooU;

    interface

    uses
      Androidapi.JNIBridge,
      Androidapi.JarBridge,
      Androidapi.JNI.JavaTypes,
      Androidapi.JNI.App;

    type
      JFoo = interface;

      JFooClass = interface(JObjectClass)
      ['{D867F94E-8E45-4F10-8045-ADB4951608FA}']
      end;

      [JavaJarSignature('com.blong.test.Foo', 'foo_dex.jar')]
      JFoo = interface(JObject)
      ['{510A0F39-4CFC-4F03-A450-06E26503ECFA}']
        {Methods}
        function Bar(Activity: JActivity): Integer; cdecl;
      end;

      TJFoo = class(TJarGenericImport)
      end;

    implementation

    end.

    As you see, it's just like any other Android SDK representation. And much like with the existing Delphi Java Bridge, I made up a helper unit to do all the heavy JNI lifting. Also, I packaged the .jar in the assets of my app such that it is immediately usable on app installation.

    ReplyDelete
  14. Vs has always been a stumbling block for me, never really gotten on with it. Too damned ingrained on d5/d7 these days, nothing compared to it in my mind.

    I can see potential in the smoking ape, don't know if they will get it right before going bang though :-)

    ReplyDelete
  15. Brian Long will the jar bridge be publicly available? That's a compelling solution

    ReplyDelete
  16. I never used Prism in the context of the Delphi IDE.  But apart from the IDE, Prism is (or rather was) Oxygene.NET

    Prism was not Oxygene for Java or for Cocoa though, and the three are not 100% compatible in terms of source code.  But neither are they 100% *in*compatible. It is possible to write code that can be shared between all three as long as you avoid things that are specific to one or other platform.  There are even language features in the compiler specifically to enable and support this (mapped types) and an open source project to create a cross-platform runtime library.

    I haven't yet turned my attention seriously to that.  But I will.  :)

    ReplyDelete
  17. I'm awaiting a final all-clear from the Embo fellas. I was obliged to lift the existing Java Bridge main class, and then change it to do the necessary. It's certainly the plan though.

    I did have another solution that required adding a virtual keyword into one of the Android RTL files, but that required the project to then recompile those Android RTL files as well as all the FMX units in use. Alas this is not without time impact - that takes a while...

    This approach (of the 3 I've tried) is neatest.

    ReplyDelete
  18. BTW Jolyon, Prism never ran in the Delphi IDE. It is (was) simply Oxygene for .NET with a different name and has always ran in VS.

    ReplyDelete
  19. Its nice, especially where you want to include jar files with your own project - hope they include it in the RTL!

    ReplyDelete
  20. Brian Long as I said, I never used it and I always did wonder about that and just assumed they found a way to fit it into RAD Studio.

    Thanks for clearing that one up for me !  :)

    ReplyDelete
  21. No such luck, you end up with Visual Studio Shell installed on your machine along with all the other VS nonsense :-)

    ReplyDelete
  22. I had always assumed that Embarcadero had done at least a little work - such as integrating the Oxygene back-end with their own IDE - to warrant putting their branding on the product.  I perhaps should have known better.  ;)


    I wouldn't call VS "nonesense" though.  Well, not all of it.  LOL  (seriously, the syntax highlighting options are woeful and the UI for configuring it is a joke compared to the facilities in Delphi)

    I vastly prefer the Delphi editor over the VisualStudio one, but this may be mostly down to familiarity and the Oxygene additions to the editor, not least specifically to facilitate cross-platform work, are a great boon.

    It 's worth noting that I haven't spent a huge amount of time figuring out how to tweak the editor, which perhaps says something about just how little a problem I am finding it to be.

    The transition to VS has certainly been a more comfortable one than to Xcode (which I did/do like in some respects) or Eclipse (which I just can't get on with), for example.

    ReplyDelete
  23. Paul Foster " will the jar bridge be publicly available? That's a compelling solution"

    Well my request to 'borrow' some of an RTL class source was denied, so I've been working on how I can still offer the same neat option with symmetry to what the product offers for the SDK.
    I have worked out an alternative that is much the same, but requires a local copy of 1 RTL file, a couple of minor edits to it, and then 1 recompile of FMX to make use of it.
    I'm quite annoyed that I have to resort to recompiling the FMX code after an RTL tweak, but apparently license transgressions just aren't on the table for a distributed solution.
    Maybe an update to the product will simplify matters. I understand they are actively working on ways to make all sorts of things more accessible - they don't want to block developer options, it's just that the RTM product does currently do this in various regards.

    ReplyDelete
  24. Brian Long thats a shame, how hard is the whole process? would your changes to FMX be kept after any updates from EMB or would we be looking at repeating the process each time?

    Alternatively how do you feel about just allowing EMB to redistribute your code themselves?  Or are they unwilling to to do that?

    ReplyDelete
  25. Paul Foster The process is quite trivial, bar the first compile of FMX, which is tedious thanks to the LLVM compiler.
    The changes to the RTL file are minimal - 3 or 4 minor tweaks so a descendant class works. Alas, it'd need tweaking with any new updates to the RTL file.
    I have offered up my solution, but it was in the context of my using the original approach, which required some RTL code distributed, so nothing happened. I'll try again after CodeRage.

    ReplyDelete
  26. Brian Long well, good luck then. heres hoping we see it in the next update :-)

    ReplyDelete
  27. Brian Long "they don't want to block developer options" ... sorry, the internet isn't very good at this sort of thing.

    Could you just clarify: did you type this with a straight face ?  ;)

    ReplyDelete
  28. Or to put it another way, they didn't intend to block develpoer avenues, have found that they have in certain scenarios that were outside what they were working towards with RTM, and having found that various things are blocked are working to making them easier to do.
    I'll be sure to find ways of mocking your expressiveness when I have nothing better to do.

    ReplyDelete
  29. Brian Long have you tried something called "a sense of humour" ?  It can be very refreshing. :)

    ReplyDelete
  30. Paul Foster Bah! My attempts to get things working with external .jar files without copying tracts of the RTL have come unstuck. Certain use cases crash wildly. Can't quite work out why. So I won't be doing that in the session. Maybe Chewie's pending import generator will including support for wrapping up external jar files?
    Clearly it works at the JNI level, but the verbosity required is unpalatable to me. And I'm annoyed I haven't been able to get things fully working with the elegant succinct approach via a few minor local RTL tweaks and a descendant class. Hey ho. I'll keep at it, on and off. Sorry about that.

    ReplyDelete
  31. Brian Long fear not, at least we know its possible - I'm sure someone will jump in.  Till then, there is my approach - as verbose as it may be!

    I may be able to come back to it, I have to clear azure off my desk first and then perhaps. I reckon the importer is just parsing the API Versions XML file to auto create interfaces and classes - rather than anything else.  I shall wait to see, hopefully I'll be proved wrong.

    I have yet to experiment with that XML file - I did wonder if adding custom class definitions to it would allow delayed loading of a JAR containing the class, but thats something for another day :-)

    ReplyDelete

  32. Hello,
    they have tried to integrate a Bluetooth connection to use the SPP profile?

    ReplyDelete
  33. You have to import the Bluetooth classes yourself from the api - we've done it and done Discovery, data exchange etc

    ReplyDelete
  34. is there already an example of the integration of XE5?

    ReplyDelete
  35. No, you have to import the android classes (easy peasy) yourself, then just follow and android documentation to get it all working, we got serial port stuff working without too much hassle

    ReplyDelete
  36. Brian Long
    On my own tests for Win32, I had to modify the Delphi/Android RTL for JNI so that it handles any form of arrays that come back from Java. So, without the modifications, I wonder if the original Delphi/Android RTL for JNI does handle any form of arrays that come back from Android.

    ReplyDelete
  37. CHUA Chee Wee  Really? Are you sure? What about, say, just randomly plucked, the getter for the activities field in PackageInfo - that returns an array of ActivityInfo objects, right? They're covered. Pretty sure quite a few others are also.
    What am I missing?

    ReplyDelete
  38. Hmm, back when I was running some tests on the JNI framework after I ported it to Win32, it doesn't seem to do handle arrays, but then, I'm talking about my own Win32 port of the JNI framework, not the original Embarcadero shipping code.

    ReplyDelete
  39. Hello there ! thanks a lot for your explanations but for this point : "you’ll have to hit the JNI to find the correct constructor (using the method signature)", this is exactly my problem... newInstance can't yield parameters, so what's the way around ? don't you have any hint or link how to instantiate more complex objects ? thanks ^^

    ReplyDelete
  40. You would simply have to do   "jCreateMethod:=TJNIResolver.GetJavaMethodID(jLoadedClassID ,'init','()I');" with the correct method signature, you would need to get that from the classes file some how, (Ljava/lang/String;I)V Method has two parameters String, Integer - void return - but will return an jobject reference.  All creators are called INIT in java land, so you have to find the method you want by dumping the DEX file, and make the right JavaEnv^.CallxxxMethod.

    ReplyDelete
  41. I saw your article and decided to ask for help.
    I'm trying to get date / time from the GPS of an Android device. I'm using Delphi XE5.
    I tried using TLocationSensor.Sensor.TimeStamp but it didn't work.
    Can you help me?

    ReplyDelete
  42. hi All, i have followed your discussion and try to ask some nubie question: on Brian long sample code
    JavaJarSignature('com.blong.test.Foo', 'foo_dex.jar')

    is the 'com.blong.test.Foo' represent a folder of jar location and 'foo_dex.jar' represent a jar file name...thanks

    ReplyDelete
  43. Aldy Wianto that code only works because Brian Long was able to change firemonkey to support it, unless you figure out how to do the same it won't work.

    ReplyDelete
  44. Paul Foster Hello Paul, I have some problems with the discovery of the bluetooth devices with XE7 and Android. Can you send me an example or link for realisation of the discovery using JNI bridge? 
    I would be very grateful for your help.

    ReplyDelete
  45. Anderson Grande, use this (http://www.cnblogs.com/hezihang/p/4481451.html) for nmea data. There is a bug in android 6 although. It works only with xe10.1 update 1. Use it like this :

    nm:=TNmeaProvider.create; nm.OnNmeaLineReceived:= Form1.OnNmeaReceived; nm.run;
    cnblogs.com - Delphi获取Android下GPS的NMEA 0183数据 - 子航 - 博客园

    ReplyDelete

Post a Comment